From 5bd0f6517ca64623b921a9fafffc0c0903296bc1 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 6 Sep 2026 17:08:29 -0400 Subject: [PATCH 1/7] docs: design GraphKit 0.3.1 IHA bridge --- ...-09-06-graphkit-0.3.1-iha-bridge-design.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-06-graphkit-0.3.1-iha-bridge-design.md diff --git a/docs/superpowers/specs/2026-09-06-graphkit-0.3.1-iha-bridge-design.md b/docs/superpowers/specs/2026-09-06-graphkit-0.3.1-iha-bridge-design.md new file mode 100644 index 0000000..c132765 --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-graphkit-0.3.1-iha-bridge-design.md @@ -0,0 +1,201 @@ +# GraphKit 0.3.1 IHA Dependency Bridge Design + +**Status:** Approved dependency direction. Adam authorized increasing dependencies as needed on +2026-09-06. Public PSGallery publication, protected live-tenant validation, customer credentials, +destructive Graph operations, stable promotion of the R8 train, and permanent Office-skill +installation remain separate gates. + +## Purpose + +TenantPulse 0.3.0 is intended to replace the machine-data, normalization, audit, checkpoint, and +report-data responsibilities of IntuneHealthAutomation (IHA). Its completed IHA-port branch uses two +Graph operations that the immutable GraphKit 0.3.0 PSGallery package does not contain: + +- `AppleEnrollmentProfile.ListByToken` +- `ManagedDevice.GetBeta` + +Those descriptors are present on GraphKit's R8 development line, but R8 deliberately identifies +itself as `0.4.0-r8`. Its approved design still separates deterministic implementation from +protected live authentication parity, removal of the transitional +`Microsoft.Graph.Authentication` dependency, and stable publication. TenantPulse must not make an +unfinished authentication train appear stable merely to obtain two read descriptors. + +GraphKit 0.3.1 is therefore a maintenance bridge. It adds only the two IHA successor reads and the +release-integrity machinery needed to prove separately packaged descriptor bytes. TenantPulse then +requires exact GraphKit 0.3.1. R8 remains an independent 0.4.0 prerelease train. + +## Release-Line Decision + +The maintenance line starts at GraphKit commit +`661de940d7fcce1a82480a12e5d360f3b51dd932`, the post-publication release-truth closeout. Its +production source, `build.ps1`, `build.yaml`, and `RequiredModules.psd1` are unchanged from published +0.3.0 merge commit `a1b0b8d54c17671761ef5aee017a453b072d1fe9`; it adds only corrected release +documentation, CI wording, publisher assertions, and QA. + +The branch topology is explicit: + +1. Create remote maintenance branch `release/0.3.x` at `661de940d7fcce1a82480a12e5d360f3b51dd932`. +2. Develop GraphKit 0.3.1 on `codex/graphkit-0.3.1-iha-bridge`. +3. Open the GraphKit pull request against `release/0.3.x`, never against R8-bearing `main`. +4. Require all six Windows, Ubuntu, and macOS PowerShell 7.4/7.6 CI jobs on the exact pull-request + head. +5. Merge only that exact reviewed head into `release/0.3.x` and verify the remote merge commit. +6. Keep R8 `main` unchanged except for a later repository-only note identifying 0.3.1 as the + maintained stable dependency. Such reconciliation is a separate, non-package change after the + maintenance release is proven. + +This avoids a version regression on `main`, keeps the stable and prerelease histories auditable, +and gives the 0.3.x package a normal reviewed merge point. + +## GraphKit 0.3.1 Package Scope + +GraphKit 0.3.1 changes the following shipped behavior and nothing else: + +- Add `source/Data/Operations/AppleEnrollmentProfile.ListByToken.psd1` as a beta-only, paged, + safe read under one explicit DEP onboarding token ID. +- Add `source/Data/Operations/ManagedDevice.GetBeta.psd1` as a beta, non-paged singleton using + `Singleton.Default`. Its fixed `$select` is the minimum IHA-successor projection for hardware and + health-attestation reporting. +- Change the module identity from 0.3.0 to 0.3.1 and provide 0.3.1 release notes. +- Preserve the existing GraphKit transport, authentication, retry, throttling, paging, permission, + export, profile, and evidence implementations byte-for-byte unless a failing bridge test exposes + a direct compatibility defect. +- Preserve `Microsoft.Graph.Authentication` 2.38.1 as the only hard runtime dependency on this + maintenance line. R8's compiled authentication adapter is not backported. + +Both descriptors declare application permissions and response expectations from the documented +service contract. Deterministic tests prove routing, path binding, paging choice, singleton +preservation, row typing, and minimum fields. They do not claim live service permission or response +verification. + +## Data Flow and Certainty + +The Apple enrollment path is: + +```text +TenantPulse depOnboardingSettings evidence + -> one AppleEnrollmentProfile.ListByToken request per valid token id + -> GraphKit beta paging and tenant-bound transport + -> typed GraphKit.AppleEnrollmentProfile rows + -> TenantPulse normalized apple-enrollment-profiles report data +``` + +The managed-device path is: + +```text +TenantPulse ManagedDevice.List evidence + -> one ManagedDevice.GetBeta request per valid Windows device id + -> GraphKit beta singleton transport with fixed $select + -> the complete singleton object, never a value-property unwrap + -> TenantPulse normalized managed-device-inventory report data +``` + +GraphKit preserves its operation-envelope certainty and provenance. TenantPulse remains responsible +for translating permission denial, authentication failure, invalid provider data, incomplete source +datasets, missing identifiers, and partial detail reads into explicit coverage/gap records. Neither +module converts missing evidence into an empty-success claim. + +## All-File Tested-Release Proof + +The GraphKit 0.3.0 publisher compares only the packaged `GraphKit.psm1` with the built module. That +is insufficient for 0.3.1 because operation descriptors are copied into the package as independent +files. A package could carry untested descriptor bytes while the compiled module hash remained +unchanged. + +GraphKit 0.3.1 introduces a stable-line tested-release proof with these authorities: + +- exact source revision and clean tracked-source state; +- exact module version `0.3.1` with no prerelease label; +- NUnit result path, SHA-256, total, failures, errors, skipped, inconclusive, and NotRun counts; +- package path, byte length, and SHA-256; +- an ordinal, case-sensitive list of every shipped package path with byte length and SHA-256; +- byte-for-byte equality between the built module tree and every corresponding package entry; +- explicit rejection of duplicate, absolute, traversal, backslash, case-colliding, unlisted, or + missing package paths; +- source-to-package checks for the two operation descriptors and module manifest; +- package privacy scanning before any outward publication. + +`scripts/New-GraphKitTestedReleaseProof.ps1` creates +`output/testResults/tested-release-proof.json` only after the package and full test result pass. +`scripts/Test-GraphKitReleaseProof.ps1` independently revalidates the proof, source revision, result, +built tree, and archive. Both `Publish-GraphKitPackage.ps1` and `Publish-GraphKitToGallery.ps1` must +consume the verified proof and must never build. + +Tests must mutation-test the verifier by altering a descriptor entry, adding an unlisted entry, +removing a listed entry, changing the result file, changing the source revision, and changing proof +metadata. Each mutation must fail closed for the expected reason. + +## TenantPulse Dependency Integration + +After the exact GraphKit 0.3.1 maintenance package and its proof exist locally, TenantPulse's +IHA-completion branch changes every dependency authority from exact 0.3.0 to exact 0.3.1: + +- `source/TenantPulse.psd1` runtime `RequiredVersion`; +- `RequiredModules.psd1` restore pin; +- build/package comments and release notes; +- clean-import, real-GraphKit, transitive-packaging, release-truth, and module-manifest tests; +- contracts that currently say the required operations are absent from published 0.3.0. + +TenantPulse tests resolve GraphKit 0.3.1 from a local package repository or isolated module root. No +test may silently fall back to R8, a globally installed GraphKit, or GraphKit 0.3.0. The resulting +TenantPulse package continues to contain no GraphKit source and depends only on exact GraphKit 0.3.1 +through normal module metadata. + +The TenantPulse collectors, schemas, coverage states, normalized row contracts, raw evidence, +checkpoint behavior, and Office-output boundary remain unchanged. The dependency correction makes +their declared producer match the producer they already require; it does not redesign report data. + +## Verification and Merge Gates + +The GraphKit maintenance change is ready for review only when all of the following are freshly true: + +1. A focused red test fails because both descriptors are absent at the baseline. +2. The two descriptors make the focused routing and response-contract tests pass. +3. `pack` runs before the authoritative full `test` task. +4. The measured test floor is updated consistently only after observing the new total. +5. The full result contains zero failures, errors, skips, inconclusive, and NotRun tests. +6. The all-file tested-release proof validates the exact package and detects descriptor tampering. +7. A clean install from the exact local package imports and resolves both descriptors without R8. +8. Independent review findings are fixed through new failing tests and the full proof is repeated. +9. All six exact-head CI jobs pass before merge to `release/0.3.x`. + +The TenantPulse change is ready for review only when: + +1. Dependency tests fail against the old 0.3.0 pin and pass against exact 0.3.1. +2. Its full package-bound suite passes with zero failed, errored, skipped, inconclusive, or NotRun + tests. +3. A clean isolated install imports TenantPulse and exact GraphKit 0.3.1 together. +4. The IHA coverage contract still reports every endpoint and active report as Complete or an + explicitly justified Replaced state, with Excel rendering remaining the deliberate external + Office-builder boundary. +5. Independent review is clean and all six exact-head CI jobs pass before merge to TenantPulse + `main`. + +## Authorization Boundaries + +The approved work may create branches and worktrees, modify dependency/version/test/documentation +files, build local packages, run deterministic tests, push review branches, open pull requests, and +merge exact-SHA CI-green changes. + +It may not, without a later explicit instruction: + +- publish GraphKit 0.3.1 or TenantPulse 0.3.0 to PSGallery; +- use or request customer-tenant credentials; +- execute protected live-tenant verification; +- perform any Graph mutation or destructive operation; +- declare the R8 authentication train stable; +- permanently install the Office builder as a skill. + +Publication readiness and publication are separate facts. Until the public gallery action and +post-publication clean-install verification are explicitly authorized and observed, documentation +must describe local package proof, remote CI, and merged source without claiming public availability. + +## Rollback + +Before publication, rollback means abandoning the 0.3.1 maintenance package and returning +TenantPulse to its last published source dependency. No tenant profile or evidence migration occurs. + +After a separately authorized publication, immutable package versions are never overwritten. +Rollback means pinning consumers to the previously verified GraphKit and TenantPulse versions while +correcting forward in new versions. The `release/0.3.x` branch remains the audit trail for the bridge; +R8 continues independently on `main`. From 62a227c2bd49446424fccb19bde8bfc8e5915e69 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 6 Sep 2026 17:12:52 -0400 Subject: [PATCH 2/7] docs: plan GraphKit 0.3.1 IHA bridge --- .../2026-09-06-graphkit-0.3.1-iha-bridge.md | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-06-graphkit-0.3.1-iha-bridge.md diff --git a/docs/superpowers/plans/2026-09-06-graphkit-0.3.1-iha-bridge.md b/docs/superpowers/plans/2026-09-06-graphkit-0.3.1-iha-bridge.md new file mode 100644 index 0000000..090a7a6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-graphkit-0.3.1-iha-bridge.md @@ -0,0 +1,417 @@ +# GraphKit 0.3.1 IHA Dependency Bridge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Produce and merge a fully proven GraphKit 0.3.1 maintenance package with the two missing IHA successor reads, then make TenantPulse 0.3.0 depend reproducibly on that exact producer. + +**Architecture:** Keep GraphKit's stable 0.3.x line separate from the R8 0.4.0 prerelease line. Add only descriptor behavior and stable-line release-proof machinery to GraphKit, then update every TenantPulse runtime, restore, test, packaging, and documentation authority from GraphKit 0.3.0 to 0.3.1. + +**Tech Stack:** PowerShell 7.4/7.6, Pester 6.1.0, Sampler 0.120.1, ModuleBuilder 3.1.8, PSResourceGet, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-09-06-graphkit-0.3.1-iha-bridge-design.md` + +## Global Constraints + +- Work only in isolated worktrees; preserve both primary checkouts and all unrelated dirty files. +- Base GraphKit maintenance work on `661de940d7fcce1a82480a12e5d360f3b51dd932` and target remote branch `release/0.3.x`. +- GraphKit 0.3.1 keeps the 0.3.0 transport/authentication implementation and exact `Microsoft.Graph.Authentication` 2.38.1 hard dependency. +- Add only `AppleEnrollmentProfile.ListByToken` and `ManagedDevice.GetBeta` to the operation surface. +- Treat documented descriptor behavior, deterministic proof, live service verification, CI, merge, and publication as distinct facts. +- Always run `pack` before `test`; publication scripts never build. +- Require zero failed, errored, skipped, inconclusive, and NotRun tests. +- Merge only a reviewed exact head whose six Windows/macOS/Ubuntu PowerShell 7.4/7.6 CI jobs pass. +- Do not publish to PSGallery, use tenant credentials, run live Graph calls, perform mutations, promote R8, or install the Office skill. + +--- + +### Task 1: Add the two stable-line IHA read contracts + +**Files:** +- Create: `source/Data/Operations/AppleEnrollmentProfile.ListByToken.psd1` +- Create: `source/Data/Operations/ManagedDevice.GetBeta.psd1` +- Modify: `tests/Unit/Operations/TenantPulseDescriptorContracts.Tests.ps1` + +**Interfaces:** +- Consumes: existing schema-v1 descriptor loader, `Collection.Default`, `Singleton.Default`, `Get-GraphObject`, `Invoke-GraphPaging`, and `Invoke-GraphHandlerStrategy`. +- Produces: `AppleEnrollmentProfile/ListByToken` and `ManagedDevice/GetBeta` operations usable through `Get-GraphObject` on GraphKit 0.3.1. + +- [ ] **Step 1: Write failing catalog and public-routing tests** + +Extend `TenantPulseDescriptorContracts.Tests.ps1` with four cases. The catalog cases assert the exact metadata below. The routing cases mock only the transport/strategy boundary and call public `Get-GraphObject`: + +```powershell +$apple = $script:catalog | Where-Object { + $_.Type -eq 'AppleEnrollmentProfile' -and $_.Operation -eq 'ListByToken' +} +$apple.PathTemplate | Should -BeExactly '/deviceManagement/depOnboardingSettings/{depOnboardingSettingId}/enrollmentProfiles' +$apple.ApiVersion | Should -BeExactly 'beta' +$apple.OperationKind | Should -BeExactly 'Collection' +$apple.HandlerStrategyId | Should -BeExactly 'Collection.Default' +$apple.PagingStrategy | Should -BeExactly 'NextLink' +@($apple.RequiredPermissions.Value) | Should -Be @('DeviceManagementServiceConfig.Read.All') + +$device = $script:catalog | Where-Object { + $_.Type -eq 'ManagedDevice' -and $_.Operation -eq 'GetBeta' +} +$device.PathTemplate | Should -BeExactly '/deviceManagement/managedDevices/{id}?$select=id,hardwareInformation,deviceHealthAttestationState,physicalMemoryInBytes,processorArchitecture,skuFamily,skuNumber,managementFeatures,roleScopeTagIds,ethernetMacAddress,bootstrapTokenEscrowed' +$device.OperationKind | Should -BeExactly 'Singleton' +$device.HandlerStrategyId | Should -BeExactly 'Singleton.Default' +$device.PagingStrategy | Should -BeExactly 'None' +@($device.RequiredPermissions.Value) | Should -Be @('DeviceManagementManagedDevices.Read.All') +``` + +For Apple data, mock `Invoke-GraphPaging`, return one polymorphic profile fixture, call +`Get-GraphObject -Type AppleEnrollmentProfile -Operation ListByToken`, and assert the typed row and +profile fields. For device data, mock `Invoke-GraphHandlerStrategy`, return a singleton whose +`hardwareInformation` and `deviceHealthAttestationState` are populated, call +`Get-GraphObject -Type ManagedDevice -Operation GetBeta`, and assert the complete object survives. + +- [ ] **Step 2: Run the focused test and verify RED** + +```powershell +pwsh -NoProfile -Command "Import-Module ./output/RequiredModules/Pester/6.1.0/Pester.psd1 -Force; Invoke-Pester -Path ./tests/Unit/Operations/TenantPulseDescriptorContracts.Tests.ps1 -Output Detailed" +``` + +Expected: the four new cases fail because both operations are absent. Existing cases remain green. + +- [ ] **Step 3: Add the two descriptor files** + +Each file is a pure PowerShell data hashtable. Use these required distinctions: + +```powershell +# AppleEnrollmentProfile.ListByToken.psd1 +@{ + SchemaVersion = 1; Type = 'AppleEnrollmentProfile'; Operation = 'ListByToken' + OperationKind = 'Collection'; HandlerStrategyId = 'Collection.Default' + ApiVersion = 'beta'; Stability = 'BetaOnly' + BetaReason = 'Apple DEP enrollment profiles are exposed only beneath the beta depOnboardingSettings relationship.' + Method = 'GET' + PathTemplate = '/deviceManagement/depOnboardingSettings/{depOnboardingSettingId}/enrollmentProfiles' + RequestBodyKind = $null; ResponseKind = 'Json'; PagingStrategy = 'NextLink' + RequiredPagingHeaders = @(); DeduplicationKey = 'id'; SupportsAll = $false; SupportsDelta = $false + ReplayPolicy = 'Safe'; Condition = $null; Reconciliation = $null + AdvancedQuery = @{ Supported = $false } + Concurrency = @{ Mode = 'None'; Header = $null; Required = $false; AllowWildcard = $false } + CredentialPolicy = 'GraphBearer'; AllowedHosts = @(); RedirectPolicy = 'None'; IdentityRequirement = 'Verified' + ResourceFamily = 'Intune.Enrollment'; ThrottleClass = 'Read' + SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + RequiredPermissions = @(@{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' }) + RequiredLicense = @('Microsoft Intune'); SupportedClouds = @('Global', 'USGov', 'USGovDoD') +} +``` + +`ManagedDevice.GetBeta.psd1` uses the exact path from Step 1, `OperationKind = 'Singleton'`, +`HandlerStrategyId = 'Singleton.Default'`, `Stability = 'DualVersion'`, `PagingStrategy = 'None'`, +`DeduplicationKey = $null`, resource family `Intune.ManagedDevices`, and application permission +`DeviceManagementManagedDevices.Read.All`; all remaining safety fields match the Apple descriptor. + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run `pack` before the same focused Pester command because descriptors are copied assets. + +Expected: every descriptor-contract case passes; Apple uses paging and managed-device detail uses the singleton handler. + +- [ ] **Step 5: Commit the descriptor slice** + +```bash +git add source/Data/Operations/AppleEnrollmentProfile.ListByToken.psd1 \ + source/Data/Operations/ManagedDevice.GetBeta.psd1 \ + tests/Unit/Operations/TenantPulseDescriptorContracts.Tests.ps1 +git commit -m "feat: add IHA successor reads to GraphKit 0.3.x" +``` + +--- + +### Task 2: Establish the immutable GraphKit 0.3.1 identity + +**Files:** +- Modify: `source/GraphKit.psd1` +- Modify: `CHANGELOG.md` +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify: `tests/QA/PackageIdentity.tests.ps1` +- Modify: `tests/QA/ReleaseTruth.tests.ps1` +- Modify: `tests/QA/InstallPinnedDependencies.tests.ps1` + +**Interfaces:** +- Consumes: GraphKit manifest and existing stable-release truth scopes. +- Produces: one stable `0.3.1` source, built manifest, package name, release-note identity, and maintenance-line narrative while preserving immutable 0.3.0 evidence. + +- [ ] **Step 1: Change QA expectations to 0.3.1 and verify RED** + +Update package-identity assertions and next-release installer fixtures to require `0.3.1`. Add release-truth assertions that 0.3.0 remains immutable and that 0.3.1 is locally prepared but not claimed published: + +```powershell +[string] $sourceManifest.ModuleVersion | Should -BeExactly '0.3.1' +$sourceManifest.PrivateData.PSData.Prerelease | Should -BeExactly '' +$sourceManifest.PrivateData.PSData.ReleaseNotes | Should -Match '^0\.3\.1(?:\r?\n)' +$readme | Should -Match '0\.3\.1.*maintenance' +$readme | Should -Not -Match '0\.3\.1.*published to PSGallery' +``` + +Run the focused QA files and confirm they fail on the old 0.3.0 identity. + +- [ ] **Step 2: Change the source identity and documentation** + +Set `ModuleVersion = '0.3.1'`, retain `Prerelease = ''`, and replace release notes with a 0.3.1 +section naming only the two read descriptors and all-file proof. Add a `## [0.3.1] - Unreleased` +changelog section. Update current maintenance-line text without replacing the historical 0.3.0 +archive hash, timestamps, SHAs, or CI runs. + +- [ ] **Step 3: Pack and run focused identity QA GREEN** + +```powershell +pwsh -NoProfile -File ./build.ps1 -Tasks pack +pwsh -NoProfile -Command "Import-Module ./output/RequiredModules/Pester/6.1.0/Pester.psd1 -Force; Invoke-Pester -Path @('./tests/QA/PackageIdentity.tests.ps1','./tests/QA/ReleaseTruth.tests.ps1','./tests/QA/InstallPinnedDependencies.tests.ps1') -Output Detailed" +``` + +Expected: source, built manifest, and `output/GraphKit.0.3.1.nupkg` agree; 0.3.0 remains historical evidence. + +- [ ] **Step 4: Commit the version slice** + +```bash +git add source/GraphKit.psd1 CHANGELOG.md README.md AGENTS.md \ + tests/QA/PackageIdentity.tests.ps1 tests/QA/ReleaseTruth.tests.ps1 \ + tests/QA/InstallPinnedDependencies.tests.ps1 +git commit -m "chore: prepare GraphKit 0.3.1 maintenance identity" +``` + +--- + +### Task 3: Bind every shipped GraphKit file to the passing result + +**Files:** +- Create: `scripts/New-GraphKitTestedReleaseProof.ps1` +- Create: `scripts/Test-GraphKitReleaseProof.ps1` +- Modify: `scripts/Publish-GraphKitPackage.ps1` +- Modify: `scripts/Publish-GraphKitToGallery.ps1` +- Create: `tests/QA/ReleaseProof.tests.ps1` +- Modify: `tests/QA/PublishChannel.tests.ps1` +- Modify: `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: `output/module/GraphKit/0.3.1`, `output/GraphKit.0.3.1.nupkg`, and the exact NUnit result from `./build.ps1 -Tasks test`. +- Produces: `output/testResults/tested-release-proof.json` and a verifier result containing `Version`, `PackageSha256`, `PackageSizeBytes`, `TestCount`, `ShippedFileCount`, `SourceRevision`, and verifier-owned package/proof snapshots. + +- [ ] **Step 1: Write fail-closed proof tests** + +`ReleaseProof.tests.ps1` builds a sealed fixture from the current package and asserts: + +```powershell +$verified = & $verifier -PackagePath $package -ProofPath $proof -RepositoryRoot $repoRoot +$verified.Version | Should -BeExactly '0.3.1' +$verified.TestCount | Should -BeGreaterOrEqual 781 +$verified.ShippedFileCount | Should -BeGreaterThan 2 +``` + +Add separate cases that copy the fixture and then: change the Apple descriptor bytes inside the ZIP, +change the managed-device descriptor bytes inside the built tree, add an unlisted ZIP entry, remove a +listed entry, introduce `../escape`, change the NUnit bytes, change `sourceRevision`, duplicate a +case-insensitive path, and point the proof at another package name. Each invocation must throw a +specific mismatch/unsafe-path error. + +- [ ] **Step 2: Run proof tests and verify RED** + +Expected: failure because both proof scripts are absent and the publishers do not require a canonical proof. + +- [ ] **Step 3: Implement proof creation** + +`New-GraphKitTestedReleaseProof.ps1` accepts mandatory `-PackagePath`, `-TestResultPath`, and +`-OutputPath`. It parses the built manifest, validates stable 0.3.1, runs the whole-result gate, +requires `git status --porcelain --untracked-files=no` to be empty for tracked source, captures +`git rev-parse HEAD`, enumerates every regular built-module file with case-sensitive normalized `/` +paths, compares that exact set and every digest with archive entries, and writes schema 1 JSON only +through a create-new temporary file followed by an atomic same-directory move. + +The proof shape is: + +```json +{ + "schemaVersion": 1, + "sourceRevision": "40 lowercase hex characters", + "module": { "name": "GraphKit", "version": "0.3.1", "files": [] }, + "package": { "name": "GraphKit.0.3.1.nupkg", "sizeBytes": 0, "sha256": "64 lowercase hex characters" }, + "testRun": { "nunit": { "name": "NUnitXml_...xml", "sha256": "64 lowercase hex characters", "total": 0, "failures": 0, "errors": 0, "skipped": 0, "inconclusive": 0, "notRun": 0 } } +} +``` + +- [ ] **Step 4: Implement independent proof verification** + +`Test-GraphKitReleaseProof.ps1` takes `-PackagePath`, `-ProofPath`, `-RepositoryRoot`, optional +`-TestResultPath`, and optional verifier-owned copy paths. It rejects symlinks/reparse points, unsafe +or duplicate paths, unknown top-level proof members, stale source revision, dirty tracked source, +result/hash/count drift, package hash/size drift, built-tree drift, archive-entry drift, and any +built/archive file-set difference. It returns only the result properties listed in Interfaces. + +- [ ] **Step 5: Make both publishers proof-gated and report-only by default** + +Remove `-SkipTestProof` from any path capable of outward publication. Both publishers call the +verifier first, operate on its verifier-owned package snapshot, and require a named `-Publish` +switch plus normal `ShouldProcess` confirmation and a secure API key before PSGallery publication. +Without all three, they print verified identity and stop successfully without publishing. + +- [ ] **Step 6: Run proof and publisher QA GREEN** + +Run `pack`, the full `test`, proof creation, proof verification, and the focused proof/publisher QA. +Expected: exact descriptor tampering and every other mutation fail closed; the untouched package verifies. + +- [ ] **Step 7: Synchronize the measured test floor** + +Read the exact total from the fresh NUnit XML and update all synchronized minimum-test authorities in +`.github/workflows/ci.yml`, `scripts/Publish-GraphKitPackage.ps1`, +`scripts/Publish-GraphKitToGallery.ps1`, and passing-result test fixtures. Re-run `pack` then the full +`test` task because the changed scripts are source/release inputs. + +- [ ] **Step 8: Commit the release-proof slice** + +```bash +git add scripts/New-GraphKitTestedReleaseProof.ps1 scripts/Test-GraphKitReleaseProof.ps1 \ + scripts/Publish-GraphKitPackage.ps1 scripts/Publish-GraphKitToGallery.ps1 \ + tests/QA/ReleaseProof.tests.ps1 tests/QA/PublishChannel.tests.ps1 .github/workflows/ci.yml +git commit -m "build: bind GraphKit 0.3.1 to all tested package files" +``` + +--- + +### Task 4: Verify, review, and merge the GraphKit maintenance release source + +**Files:** +- Modify only files required by review findings, with a failing regression test first. +- Modify: `docs/superpowers/specs/2026-09-06-graphkit-0.3.1-iha-bridge-design.md` +- Modify: `docs/superpowers/plans/2026-09-06-graphkit-0.3.1-iha-bridge.md` + +**Interfaces:** +- Consumes: completed GraphKit 0.3.1 source and local proof. +- Produces: reviewed exact-SHA CI-green merge on remote `release/0.3.x`, without publication. + +- [ ] **Step 1: Run the authoritative local gate** + +Run `pack`, full `test`, standalone whole-result gate, proof creation, proof verification, privacy +scan, and clean isolated install. Record package SHA-256, size, shipped-file count, test total, and +source revision. + +- [ ] **Step 2: Review the exact diff** + +Compare `661de94..HEAD` for correctness, silent failure, dependency drift, privacy, package integrity, +and simplification. Every fix begins with a failing test. Repeat the authoritative gate after the +last edit. + +- [ ] **Step 3: Create the maintenance target and pull request** + +Push `661de940d7fcce1a82480a12e5d360f3b51dd932` as `release/0.3.x` only if that remote ref does not +exist. Push `codex/graphkit-0.3.1-iha-bridge`, open a PR targeting `release/0.3.x`, and record the exact +head SHA. + +- [ ] **Step 4: Require exact-head remote proof** + +Wait for all six unique OS/PowerShell jobs. If review changes the head, repeat all local proof and +wait for six jobs on the new exact head. Do not merge an older green SHA. + +- [ ] **Step 5: Merge and verify remote state** + +Merge the PR, verify `refs/heads/release/0.3.x` resolves to the reported merge commit, and retain the +local tested package/proof. Do not call either publisher with `-Publish`. + +--- + +### Task 5: Update TenantPulse to exact GraphKit 0.3.1 + +**Files:** +- Modify: `/Users/Adam.Gell/repo/TenantPulse/RequiredModules.psd1` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/source/TenantPulse.psd1` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/build.yaml` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/README.md` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/CHANGELOG.md` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/docs/contracts/application-report-data-v1.md` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/docs/contracts/audit-inventory-v1.md` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/docs/contracts/audit-report-data-v1.md` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/docs/contracts/device-report-data-v1.md` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/docs/contracts/iha-migration-and-recovery-v1.md` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/source/Data/DatasetMap.psd1` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/tests/QA/ModuleManifest.tests.ps1` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/tests/QA/ReadOnly.tests.ps1` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/tests/QA/ReleaseTruth.tests.ps1` +- Modify: `/Users/Adam.Gell/repo/TenantPulse/tests/QA/TransitiveRuntimePackaging.tests.ps1` +- Modify any additional exact `0.3.0` authority returned by the scoped repository search. + +**Interfaces:** +- Consumes: exact local GraphKit 0.3.1 package and verified GraphKit maintenance merge. +- Produces: TenantPulse 0.3.0 whose runtime, restore, clean-install, packaging, tests, and documentation all require exact GraphKit 0.3.1. + +- [ ] **Step 1: Restore the clean TenantPulse IHA branch in an isolated worktree** + +Create a new worktree for existing branch `codex/iha-port-completion` at +`a05aa3786474e32baae61be5a8b04316fc9b49c3`; do not touch the dirty primary checkout. Resolve its +dependencies and run the current full suite to establish the branch baseline. + +- [ ] **Step 2: Change dependency assertions to 0.3.1 and verify RED** + +Change QA assertions first: + +```powershell +[string] $requirements[0].RequiredVersion | Should -BeExactly '0.3.1' +[string] $restoreDependencies.GraphKit | Should -BeExactly '0.3.1' +$probe.GraphKit | Should -BeExactly '0.3.1' +``` + +Add a real-catalog assertion that both IHA operations resolve from the exact dependency. Run the +focused QA and confirm failure while manifests still pin 0.3.0. + +- [ ] **Step 3: Update every dependency authority and narrative** + +Change runtime and restore pins to exact 0.3.1. Update only statements about producer availability; +do not change the IHA row schemas, coverage classifications, raw evidence, certainty, checkpoint, +or Office-output boundary. Replace claims that the two operations are absent with the accurate +0.3.1 contract, while retaining 0.3.0 as immutable history. + +- [ ] **Step 4: Restore exact GraphKit 0.3.1 locally and run focused QA GREEN** + +Use an isolated local PSResource repository or module root populated from the verified GraphKit +0.3.1 package. Resolve TenantPulse dependencies from that source without consulting a globally +installed GraphKit. Run manifest, real-catalog, and transitive-packaging QA and require exact 0.3.1. + +- [ ] **Step 5: Commit the dependency integration** + +Stage only the scoped TenantPulse files and commit: + +```bash +git commit -m "build: require GraphKit 0.3.1 for IHA reports" +``` + +--- + +### Task 6: Verify, review, and merge TenantPulse + +**Files:** +- Modify only files required by review findings, with a failing regression test first. + +**Interfaces:** +- Consumes: TenantPulse IHA successor branch plus exact local GraphKit 0.3.1. +- Produces: reviewed exact-SHA CI-green TenantPulse main containing the complete IHA successor and correct dependency, without PSGallery publication. + +- [ ] **Step 1: Run full package-bound verification** + +Run `pack` before `test`, the standalone whole-result gate, TenantPulse tested-release proof, +publisher dry-run, transitive clean-install test, IHA coverage QA, source/privacy scans, and an +independent package-content comparison. Record exact package and source identities. + +- [ ] **Step 2: Review the complete branch diff** + +Review `origin/main...HEAD`, including all IHA endpoint/data/transformation/audit/checkpoint/export/ +report contracts and the new GraphKit dependency. Fix findings test-first and repeat the full gate. + +- [ ] **Step 3: Open the TenantPulse PR and require exact-head CI** + +Push the branch, open one PR against TenantPulse `main`, and require all six OS/PowerShell jobs for +the exact final head. Any head change invalidates older local and CI evidence. + +- [ ] **Step 4: Merge and verify the remote main commit** + +Merge only after exact-head review and CI are green. Verify remote `main`, the PR merge state, and +the retained local package/proof. Do not invoke the TenantPulse publisher with `-Publish`. + +- [ ] **Step 5: Close the handoff** + +Notify the customer-document, portable Office-builder, and final-release tasks that source and CI +blockers are removed. State separately that PSGallery publication, live tenant evidence, and +permanent skill installation remain unperformed authorization gates. Stop after this handoff. From bd4c3a240b3e64c34ef8abafe21ea31e7b7579d5 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 6 Sep 2026 17:15:43 -0400 Subject: [PATCH 3/7] feat: add IHA successor reads to GraphKit 0.3.x --- .../AppleEnrollmentProfile.ListByToken.psd1 | 56 ++++++++ .../Operations/ManagedDevice.GetBeta.psd1 | 56 ++++++++ .../TenantPulseDescriptorContracts.Tests.ps1 | 125 ++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 source/Data/Operations/AppleEnrollmentProfile.ListByToken.psd1 create mode 100644 source/Data/Operations/ManagedDevice.GetBeta.psd1 diff --git a/source/Data/Operations/AppleEnrollmentProfile.ListByToken.psd1 b/source/Data/Operations/AppleEnrollmentProfile.ListByToken.psd1 new file mode 100644 index 0000000..d2d167d --- /dev/null +++ b/source/Data/Operations/AppleEnrollmentProfile.ListByToken.psd1 @@ -0,0 +1,56 @@ +<# + Operation descriptor - data only. Loaded with Import-PowerShellDataFile. + + Lists the Apple Automated Device Enrollment profiles attached to one DEP onboarding + token. Microsoft exposes this relationship only in beta. The token id is explicit in + the path so callers cannot accidentally treat profiles from different Apple tokens as + one tenant-wide collection. + + The descriptor and deterministic response contract are implemented from Microsoft's + documented path and application permission. Live service permission/shape verification + remains a separate release gate. +#> +@{ + SchemaVersion = 1 + + Type = 'AppleEnrollmentProfile' + Operation = 'ListByToken' + OperationKind = 'Collection' + HandlerStrategyId = 'Collection.Default' + + ApiVersion = 'beta' + Stability = 'BetaOnly' + BetaReason = 'Apple DEP enrollment profiles are exposed only beneath the beta depOnboardingSettings relationship.' + + Method = 'GET' + PathTemplate = '/deviceManagement/depOnboardingSettings/{depOnboardingSettingId}/enrollmentProfiles' + RequestBodyKind = $null + ResponseKind = 'Json' + PagingStrategy = 'NextLink' + RequiredPagingHeaders = @() + DeduplicationKey = 'id' + SupportsAll = $false + SupportsDelta = $false + + ReplayPolicy = 'Safe' + Condition = $null + Reconciliation = $null + + AdvancedQuery = @{ Supported = $false } + Concurrency = @{ Mode = 'None'; Header = $null; Required = $false; AllowWildcard = $false } + + CredentialPolicy = 'GraphBearer' + AllowedHosts = @() + RedirectPolicy = 'None' + IdentityRequirement = 'Verified' + + ResourceFamily = 'Intune.Enrollment' + ThrottleClass = 'Read' + + SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + RequiredPermissions = @( + @{ Type = 'Application'; Value = 'DeviceManagementServiceConfig.Read.All' } + ) + RequiredLicense = @('Microsoft Intune') + SupportedClouds = @('Global', 'USGov', 'USGovDoD') +} diff --git a/source/Data/Operations/ManagedDevice.GetBeta.psd1 b/source/Data/Operations/ManagedDevice.GetBeta.psd1 new file mode 100644 index 0000000..332b4e4 --- /dev/null +++ b/source/Data/Operations/ManagedDevice.GetBeta.psd1 @@ -0,0 +1,56 @@ +<# + Operation descriptor - data only. Loaded with Import-PowerShellDataFile. + + Beta singleton sibling used when a caller needs the full per-device hardware and + health-attestation shape. Microsoft documents that many hardwareInformation values are + default/null on collection reads and require a GET for the device id with the property + included in $select. This operation pins the minimum safe detail projection in its path, + along with the service version, permission, and read semantics; callers cannot broaden it + into credential-like properties through arbitrary query input. + + Live response/permission verification remains separate from this deterministic contract. +#> +@{ + SchemaVersion = 1 + + Type = 'ManagedDevice' + Operation = 'GetBeta' + OperationKind = 'Singleton' + HandlerStrategyId = 'Singleton.Default' + + ApiVersion = 'beta' + Stability = 'DualVersion' + BetaReason = 'The beta singleton carries the detailed hardware and device-health shape required by the IHA successor.' + + Method = 'GET' + PathTemplate = '/deviceManagement/managedDevices/{id}?$select=id,hardwareInformation,deviceHealthAttestationState,physicalMemoryInBytes,processorArchitecture,skuFamily,skuNumber,managementFeatures,roleScopeTagIds,ethernetMacAddress,bootstrapTokenEscrowed' + RequestBodyKind = $null + ResponseKind = 'Json' + PagingStrategy = 'None' + RequiredPagingHeaders = @() + DeduplicationKey = $null + SupportsAll = $false + SupportsDelta = $false + + ReplayPolicy = 'Safe' + Condition = $null + Reconciliation = $null + + AdvancedQuery = @{ Supported = $false } + Concurrency = @{ Mode = 'None'; Header = $null; Required = $false; AllowWildcard = $false } + + CredentialPolicy = 'GraphBearer' + AllowedHosts = @() + RedirectPolicy = 'None' + IdentityRequirement = 'Verified' + + ResourceFamily = 'Intune.ManagedDevices' + ThrottleClass = 'Read' + + SupportedAuthModes = @('Certificate', 'ClientSecret', 'ManagedIdentity') + RequiredPermissions = @( + @{ Type = 'Application'; Value = 'DeviceManagementManagedDevices.Read.All' } + ) + RequiredLicense = @('Microsoft Intune') + SupportedClouds = @('Global', 'USGov', 'USGovDoD') +} diff --git a/tests/Unit/Operations/TenantPulseDescriptorContracts.Tests.ps1 b/tests/Unit/Operations/TenantPulseDescriptorContracts.Tests.ps1 index 52b7315..da0f7ef 100644 --- a/tests/Unit/Operations/TenantPulseDescriptorContracts.Tests.ps1 +++ b/tests/Unit/Operations/TenantPulseDescriptorContracts.Tests.ps1 @@ -6,6 +6,27 @@ BeforeAll { Import-Module (Join-Path $built.FullName 'GraphKit.psd1') -Force $script:catalog = @(Get-GraphOperation -List) + + $script:Context = [PSCustomObject]@{ + Cloud = 'Global' + GraphBaseUri = [uri] 'https://graph.microsoft.com' + ProfileId = 'contract-test' + TenantId = [guid] '00000000-0000-0000-0000-000000000001' + IdentityState = 'VerifiedForToken' + } + + function New-ContractEnvelope { + param([object[]] $Data) + + [PSCustomObject]@{ + PSTypeName = 'GraphKit.OperationResult' + Data = $Data + Outcome = 'Succeeded' + Certainty = 'Known' + Telemetry = @() + Provenance = $null + } + } } Describe 'TenantPulse collection descriptor contracts' { @@ -95,4 +116,108 @@ Describe 'TenantPulse collection descriptor contracts' { $_.PathTemplate -eq '/deviceManagement/managedDeviceCleanupSettings' }).Count | Should -Be 0 } + + It 'ships the beta Apple enrollment-profile child collection needed by the IHA successor' { + $descriptor = $script:catalog | Where-Object { + $_.Type -eq 'AppleEnrollmentProfile' -and $_.Operation -eq 'ListByToken' + } + + $descriptor | Should -Not -BeNullOrEmpty + $descriptor.PathTemplate | Should -BeExactly '/deviceManagement/depOnboardingSettings/{depOnboardingSettingId}/enrollmentProfiles' + $descriptor.ApiVersion | Should -BeExactly 'beta' + $descriptor.Stability | Should -BeExactly 'BetaOnly' + $descriptor.OperationKind | Should -BeExactly 'Collection' + $descriptor.HandlerStrategyId | Should -BeExactly 'Collection.Default' + $descriptor.PagingStrategy | Should -BeExactly 'NextLink' + $descriptor.DeduplicationKey | Should -BeExactly 'id' + $descriptor.ReplayPolicy | Should -BeExactly 'Safe' + @($descriptor.RequiredPermissions.Value) | Should -Be @('DeviceManagementServiceConfig.Read.All') + } + + It 'routes Apple enrollment profiles through the public paged read without dropping subtype fields' { + $script:AppleProfile = [pscustomobject]@{ + '@odata.type' = '#microsoft.graph.depIOSEnrollmentProfile' + id = 'profile-1' + displayName = 'Corporate iOS' + description = 'Automated enrollment' + requiresUserAuthentication = $true + configurationEndpointUrl = 'https://example.test/configuration' + enableAuthenticationViaCompanyPortal = $true + requireCompanyPortalOnSetupAssistantEnrolledDevices = $true + isDefault = $true + isMandatory = $false + } + + Mock Invoke-GraphPaging -ModuleName GraphKit { + New-ContractEnvelope -Data @($script:AppleProfile) + } + Mock Invoke-GraphHandlerStrategy -ModuleName GraphKit { throw 'unexpected singleton strategy call' } + + $rows = @(Get-GraphObject -Context $script:Context -Type AppleEnrollmentProfile ` + -Operation ListByToken -Parameters @{ depOnboardingSettingId = 'token-1' }) + + $rows | Should -HaveCount 1 + $rows[0].PSObject.TypeNames | Should -Contain 'GraphKit.AppleEnrollmentProfile' + $rows[0].'@odata.type' | Should -BeExactly '#microsoft.graph.depIOSEnrollmentProfile' + $rows[0].displayName | Should -BeExactly 'Corporate iOS' + $rows[0].requiresUserAuthentication | Should -BeTrue + $rows[0].isDefault | Should -BeTrue + $rows[0].isMandatory | Should -BeFalse + Should-Invoke Invoke-GraphPaging -ModuleName GraphKit -Times 1 -Exactly + Should-NotInvoke Invoke-GraphHandlerStrategy -ModuleName GraphKit + } + + It 'ships the beta managed-device singleton needed for authoritative hardware detail' { + $descriptor = $script:catalog | Where-Object { + $_.Type -eq 'ManagedDevice' -and $_.Operation -eq 'GetBeta' + } + + $descriptor | Should -Not -BeNullOrEmpty + $descriptor.PathTemplate | Should -BeExactly '/deviceManagement/managedDevices/{id}?$select=id,hardwareInformation,deviceHealthAttestationState,physicalMemoryInBytes,processorArchitecture,skuFamily,skuNumber,managementFeatures,roleScopeTagIds,ethernetMacAddress,bootstrapTokenEscrowed' + $descriptor.ApiVersion | Should -BeExactly 'beta' + $descriptor.Stability | Should -BeExactly 'DualVersion' + $descriptor.OperationKind | Should -BeExactly 'Singleton' + $descriptor.HandlerStrategyId | Should -BeExactly 'Singleton.Default' + $descriptor.PagingStrategy | Should -BeExactly 'None' + $descriptor.DeduplicationKey | Should -BeNullOrEmpty + $descriptor.ReplayPolicy | Should -BeExactly 'Safe' + @($descriptor.RequiredPermissions.Value) | Should -Be @('DeviceManagementManagedDevices.Read.All') + } + + It 'routes managed-device detail through the singleton handler without unwrapping the object' { + $script:ManagedDeviceDetail = [pscustomobject]@{ + id = 'device-1' + operatingSystem = 'Windows' + hardwareInformation = [pscustomobject]@{ + serialNumber = 'SERIAL' + totalStorageSpace = 1024 + freeStorageSpace = 512 + tpmVersion = '2.0' + } + deviceHealthAttestationState = [pscustomobject]@{ + secureBoot = 'enabled' + bitLockerStatus = 'secured' + tpmVersion = '2.0' + } + } + + Mock Invoke-GraphHandlerStrategy -ModuleName GraphKit { + New-ContractEnvelope -Data @($script:ManagedDeviceDetail) + } + Mock Invoke-GraphPaging -ModuleName GraphKit { throw 'unexpected paging call' } + + $rows = @(Get-GraphObject -Context $script:Context -Type ManagedDevice ` + -Operation GetBeta -Parameters @{ id = 'device-1' }) + + $rows | Should -HaveCount 1 + $rows[0].PSObject.TypeNames | Should -Contain 'GraphKit.ManagedDevice' + $rows[0].id | Should -BeExactly 'device-1' + $rows[0].hardwareInformation.tpmVersion | Should -BeExactly '2.0' + $rows[0].deviceHealthAttestationState.secureBoot | Should -BeExactly 'enabled' + Should-Invoke Invoke-GraphHandlerStrategy -ModuleName GraphKit -Times 1 -Exactly -ParameterFilter { + $Descriptor.OperationKind -eq 'Singleton' -and + $Descriptor.HandlerStrategyId -eq 'Singleton.Default' + } + Should-NotInvoke Invoke-GraphPaging -ModuleName GraphKit + } } From f087513a3713cbd91c2f45bdee30e7d04e28429b Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 6 Sep 2026 17:19:15 -0400 Subject: [PATCH 4/7] chore: prepare GraphKit 0.3.1 maintenance identity --- AGENTS.md | 2 ++ CHANGELOG.md | 13 +++++++++++++ README.md | 12 +++++++++--- source/GraphKit.psd1 | 19 +++++++++++++++---- tests/QA/InstallPinnedDependencies.tests.ps1 | 4 ++-- tests/QA/PackageIdentity.tests.ps1 | 13 +++++++------ tests/QA/ReleaseTruth.tests.ps1 | 12 +++++++++--- 7 files changed, 57 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9b885b7..53437b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,8 @@ GraphKit is an app-only, multi-tenant Microsoft Graph execution and analysis lay **Current release status:** GraphKit `0.3.0` is the current immutable release on PSGallery, published at `2026-08-30T04:38:20.12Z`. The 207381-byte public archive SHA-256 is `45319d7cf4f8333697343ccf9c1089c7e04da87a8df62553cbc140089337536d`; reviewed PR head `a0f0e92a054fe2976ca74a844f5de6161e1b8c67` merged to main as `a1b0b8d54c17671761ef5aee017a453b072d1fe9`. PR-head CI run `33292245900` and exact-main CI run `33292580847` each passed 772 tests across all six Windows/macOS/Ubuntu PowerShell 7.4/7.6 jobs. This deterministic and CI evidence is not live service proof. GraphKit `0.2.2` remains an immutable predecessor; its hard SecretManagement contract matters only to hosts pinned to that version. Phases 1-5 are implemented; live verification remains recorded separately per auth mode, descriptor, and operation because implementation is not evidence of service behavior. +GraphKit `0.3.1` is an unpublished maintenance bridge candidate for TenantPulse's IntuneHealthAutomation successor path. It adds only `AppleEnrollmentProfile.ListByToken` and `ManagedDevice.GetBeta` above the stable `0.3.0` package inputs. Their catalog, route, and result-shape behavior is deterministic-test evidence only; no live-service verification or PSGallery publication is claimed. Keep this stable-line bridge separate from the broader R8 development line. + Phase 1 (Core): descriptor catalog and strategy registry, immutable contexts and locked atomic profile store, four token sources with single-flight, owned transport with tenant-proof binding, semantics-aware retry, scoped throttle with AIMD admission (starting conservatively and ramping to the cap), URI security, paging, batch, vault credential resolution, and the MSAL import guard. Phases 2-4 ship `Get-GraphObject` with tab completion, four-state permission analysis, and `Export-GraphResult` with an evidence DTO allowlist. **Gate 1.8 passed live against the Ivy24 lab tenant on 2026-08-15**: package digest verified, profile resolved SecretStore -> PFX -> MSAL, token acquired, 13 managed devices / 75 mobile apps / 15 device configurations read, and an app assignment applied to a test group and reverted (revert confirmed independently, not from the script's own report). diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf6bed..151adca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +GraphKit `0.3.1` is an unpublished maintenance bridge candidate for TenantPulse's +IntuneHealthAutomation successor path. It has not been published to PSGallery and does not replace +the immutable `0.3.0` release evidence below. + +### Added + +- `AppleEnrollmentProfile.ListByToken`, a beta paged collection descriptor for Apple enrollment + profiles scoped to an enrollment program token. +- `ManagedDevice.GetBeta`, a beta singleton descriptor that retains the hardware and attestation + detail required by TenantPulse evidence collection. +- Deterministic catalog, public-route, nested-shape, and subtype-preservation tests for both + descriptors. No live-service verification is claimed for either new operation. + ## [0.3.0] - 2026-08-30 GraphKit `0.3.0` was published to PSGallery at `2026-08-30T04:38:20.12Z`. The 207381-byte public diff --git a/README.md b/README.md index 8bca810..ef1c5c8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,11 @@ descriptors described in `CHANGELOG.md`. GraphKit `0.2.2` is an immutable predecessor. Its hard SecretManagement contract remains relevant only for hosts pinned to that version. + +GraphKit `0.3.1` is an unpublished maintenance bridge candidate for TenantPulse. It adds the beta +Apple enrollment-profile collection and beta managed-device singleton required by the +IntuneHealthAutomation successor path. Those routes are deterministically tested; no live-service +verification or PSGallery publication is claimed for `0.3.1`. ## What it provides @@ -44,13 +49,14 @@ GraphKit does not use `Connect-MgGraph` or `Invoke-MgGraphRequest` as its transp - PowerShell 7.4 or later - `Microsoft.Graph.Authentication` 2.38.1 or later, used as the MSAL delivery dependency - `Microsoft.PowerShell.SecretManagement` 1.1.2 or later only when using vault-backed credentials; - `0.3.0` validates and imports it at first vault use + `0.3.0` and later validate and import it at first vault use - A registered SecretManagement vault extension when using stored credentials Published `0.2.2` still declares SecretManagement as a hard dependency. The pinned installer -preserves that immutable package contract while treating SecretManagement as opt-in for `0.3.0`. +preserves that immutable package contract while treating SecretManagement as opt-in for `0.3.0` +and later. Import, help, catalog inspection, managed identity, injected credentials, and Windows certificate- -store credentials do not require SecretManagement in `0.3.0`. The certificate example below uses +store credentials do not require SecretManagement in `0.3.0` and later. The certificate example below uses a vault reference and therefore exercises the optional vault path. ## Typical usage diff --git a/source/GraphKit.psd1 b/source/GraphKit.psd1 index 8a3a5c4..457ba33 100644 --- a/source/GraphKit.psd1 +++ b/source/GraphKit.psd1 @@ -12,7 +12,7 @@ RootModule = 'GraphKit.psm1' # Version number of this module. -ModuleVersion = '0.3.0' +ModuleVersion = '0.3.1' # Supported PSEditions # CompatiblePSEditions = @() @@ -129,9 +129,20 @@ PrivateData = @{ # ReleaseNotes of this module ReleaseNotes = @' -0.3.0 +0.3.1 -Integrated next package. The published PSGallery 0.2.2 artifact remains immutable. +Stable-line maintenance bridge for TenantPulse's IntuneHealthAutomation successor path. +This package has not been published to PSGallery. + +ADDED +- AppleEnrollmentProfile.ListByToken for beta Apple enrollment profile collection reads. +- ManagedDevice.GetBeta for beta single-device detail reads, including hardware and + attestation fields used by TenantPulse evidence collection. + +VERIFICATION BOUNDARY +- Descriptor catalog and routed result-shape behavior are covered deterministically. +- No live-service verification is claimed for these two maintenance descriptors. +- The published PSGallery 0.3.0 and 0.2.2 artifacts remain immutable. CHANGED - Microsoft.PowerShell.SecretManagement 1.1.2+ is resolved only at first vault use. @@ -139,7 +150,7 @@ CHANGED - Vault commands are module-qualified and the boundary rejects an unavailable or too-old SecretManagement module instead of accepting unrelated same-named functions. - Install-GraphKitPinned installs only hard Microsoft.Graph.Authentication by default for - 0.3.0, offers -InstallSecretManagement for vault hosts, and preserves automatic + 0.3.1, offers -InstallSecretManagement for vault hosts, and preserves automatic SecretManagement installation for immutable 0.2.2 pins. ADDED AND LIVE-VERIFIED 2026-08-29 diff --git a/tests/QA/InstallPinnedDependencies.tests.ps1 b/tests/QA/InstallPinnedDependencies.tests.ps1 index e08d7b4..0dc5096 100644 --- a/tests/QA/InstallPinnedDependencies.tests.ps1 +++ b/tests/QA/InstallPinnedDependencies.tests.ps1 @@ -49,7 +49,7 @@ Describe 'Install-GraphKitPinned dependency selection' -Tag 'QA' { } It 'installs only hard Graph Authentication by default for a next-release package' { - $pin = New-TestPin -Root (Join-Path $TestDrive 'next-default') -Version '0.3.0' + $pin = New-TestPin -Root (Join-Path $TestDrive 'next-default') -Version '0.3.1' { & $script:installer -PinPath $pin -RestoreRecordPath (Join-Path $TestDrive 'next-default/restore.json') } | Should -Throw -ExpectedMessage '*stop-after-dependencies*' @@ -58,7 +58,7 @@ Describe 'Install-GraphKitPinned dependency selection' -Tag 'QA' { } It 'installs lazy SecretManagement for a next-release package only when explicitly requested' { - $pin = New-TestPin -Root (Join-Path $TestDrive 'next-vault') -Version '0.3.0' + $pin = New-TestPin -Root (Join-Path $TestDrive 'next-vault') -Version '0.3.1' { & $script:installer -PinPath $pin -RestoreRecordPath (Join-Path $TestDrive 'next-vault/restore.json') -InstallSecretManagement } | Should -Throw -ExpectedMessage '*stop-after-dependencies*' diff --git a/tests/QA/PackageIdentity.tests.ps1 b/tests/QA/PackageIdentity.tests.ps1 index 3aea774..73a7af6 100644 --- a/tests/QA/PackageIdentity.tests.ps1 +++ b/tests/QA/PackageIdentity.tests.ps1 @@ -2,7 +2,7 @@ BeforeAll { Add-Type -AssemblyName System.IO.Compression.FileSystem $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath - $script:expectedVersion = '0.3.0' + $script:expectedVersion = '0.3.1' $script:sourceManifestPath = Join-Path $script:repoRoot 'source/GraphKit.psd1' $script:builtManifestPath = Join-Path $script:repoRoot "output/module/GraphKit/$script:expectedVersion/GraphKit.psd1" $script:packagePath = Join-Path $script:repoRoot "output/GraphKit.$script:expectedVersion.nupkg" @@ -31,19 +31,20 @@ BeforeAll { } Describe 'GraphKit release package identity' -Tag 'QA' { - It 'declares released version 0.3.0 in source and release metadata' { + It 'declares maintenance version 0.3.1 in source and release metadata' { $source = Import-PowerShellDataFile $script:sourceManifestPath [string] $source.ModuleVersion | Should -Be $script:expectedVersion - [string] $source.PrivateData.PSData.ReleaseNotes | Should -Match '^0\.3\.0(?:\r?\n)' + [string] $source.PrivateData.PSData.ReleaseNotes | Should -Match '^0\.3\.1(?:\r?\n)' + [string] $source.PrivateData.PSData.Prerelease | Should -BeNullOrEmpty } - It 'builds and packages the 0.3.0 identity' { + It 'builds and packages the 0.3.1 identity' { Test-Path $script:builtManifestPath -PathType Leaf | Should -BeTrue Test-Path $script:packagePath -PathType Leaf | Should -BeTrue } - It 'preserves 0.3.0 in the built manifest and exact package metadata' { + It 'preserves 0.3.1 in the built manifest and exact package metadata' { Test-Path $script:builtManifestPath -PathType Leaf | Should -BeTrue Test-Path $script:packagePath -PathType Leaf | Should -BeTrue @@ -53,7 +54,7 @@ Describe 'GraphKit release package identity' -Tag 'QA' { [string] $packageMetadata.version | Should -Be $script:expectedVersion } - It 'preserves 0.3.0 in the manifest extracted from the exact nupkg' { + It 'preserves 0.3.1 in the manifest extracted from the exact nupkg' { Test-Path $script:packagePath -PathType Leaf | Should -BeTrue $extractRoot = Join-Path $TestDrive 'release' diff --git a/tests/QA/ReleaseTruth.tests.ps1 b/tests/QA/ReleaseTruth.tests.ps1 index 00de307..c49fa62 100644 --- a/tests/QA/ReleaseTruth.tests.ps1 +++ b/tests/QA/ReleaseTruth.tests.ps1 @@ -5,6 +5,7 @@ BeforeAll { $changelog = Get-Content -LiteralPath (Join-Path $repoRoot 'CHANGELOG.md') -Raw $integrationPlan = Get-Content -LiteralPath (Join-Path $repoRoot 'docs/superpowers/plans/2026-08-29-graphkit-0.3.0-integration.md') -Raw $manifestPath = Join-Path $repoRoot 'source/GraphKit.psd1' + $maintenanceScopes = @($readme, $agents, $changelog) $readmeCurrentRelease = [regex]::Match( $readme, @@ -61,10 +62,15 @@ Describe 'GraphKit current release truth' -Tag 'QA' { Assert-CurrentReleaseEvidence -Text $changelogCurrentRelease -Location 'CHANGELOG 0.3.0 release section' } - It 'preserves the immutable released manifest identity' { + It 'preserves the immutable 0.3.0 publication evidence while identifying 0.3.1 as unpublished maintenance work' { + foreach ($scope in $maintenanceScopes) { + $scope | Should -Match '(?is)0\.3\.1.*(?:maintenance|bridge).*(?:candidate|unpublished|not published)' + } + $manifest = Import-PowerShellDataFile $manifestPath - [string] $manifest.ModuleVersion | Should -Be '0.3.0' - [string] $manifest.PrivateData.PSData.ReleaseNotes | Should -Match '^0\.3\.0(?:\r?\n)' + [string] $manifest.ModuleVersion | Should -Be '0.3.1' + [string] $manifest.PrivateData.PSData.ReleaseNotes | Should -Match '^0\.3\.1(?:\r?\n)' + [string] $manifest.PrivateData.PSData.Prerelease | Should -BeNullOrEmpty } It 'marks the dated integration plan as executed and superseded by publication evidence' { From e3c1ccf38063e7e40d88536c281400c648c6931f Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 6 Sep 2026 17:34:12 -0400 Subject: [PATCH 5/7] build: bind GraphKit 0.3.1 to all tested package files --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 193 ++++++++++++ scripts/Publish-GraphKitPackage.ps1 | 336 +++++++-------------- scripts/Publish-GraphKitToGallery.ps1 | 292 +++++------------- scripts/Test-GraphKitReleaseProof.ps1 | 192 ++++++++++++ source/GraphKit.psd1 | 2 +- tests/QA/MinimumTestsRatchetSync.tests.ps1 | 13 +- tests/QA/PublishChannel.tests.ps1 | 198 ++++-------- tests/QA/ReleaseProof.tests.ps1 | 211 +++++++++++++ 10 files changed, 847 insertions(+), 594 deletions(-) create mode 100644 scripts/New-GraphKitTestedReleaseProof.ps1 create mode 100644 scripts/Test-GraphKitReleaseProof.ps1 create mode 100644 tests/QA/ReleaseProof.tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49f32b3..8049ac5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,4 +93,4 @@ jobs: if ($resultFiles.Count -gt 1) { throw "Multiple NUnit result files produced: $($resultFiles.Name -join ', ')" } - pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 777 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 789 -AllowedSkips 0 diff --git a/AGENTS.md b/AGENTS.md index 53437b9..baa9554 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Two things about the container are worth knowing before repeating it. The immuta Run the suite through `./build.ps1 -Tasks test`, never `Invoke-Pester ./tests` directly: the changelog checks are Sampler-generated and depend on build-injected variables, so a bare Pester run reports two false failures. -**Remote CI contract.** `.github/workflows/ci.yml` runs PowerShell 7.4 and 7.6 across Windows, Ubuntu, and macOS. A source revision is CI-verified only when all six matrix jobs pass for that exact SHA; workflow existence or an older successful run is not evidence. The published `0.3.0` evidence is 772 deterministic tests. The post-release development tree requires 777 deterministic tests under `./build.ps1 -Tasks test`, with zero failures, errors, or skips, and `tests/QA/Assert-GateResult.ps1` enforces the same minimum-count floor used by CI and package verification. +**Remote CI contract.** `.github/workflows/ci.yml` runs PowerShell 7.4 and 7.6 across Windows, Ubuntu, and macOS. A source revision is CI-verified only when all six matrix jobs pass for that exact SHA; workflow existence or an older successful run is not evidence. The published `0.3.0` evidence is 772 deterministic tests. The `0.3.1` maintenance tree requires 789 deterministic tests under `./build.ps1 -Tasks test`, with zero failures, errors, or skips, and its all-file tested-release proof enforces the same minimum-count floor used by CI and package verification. **Phase 5 (cutover) implementation and Ivy24 verification are complete.** All eight steps ran and were verified against the Ivy24 lab tenant: legacy-caller inventory, `Import-GraphLegacyProfile`, a private versioned package channel with publish/pin/install, a live read through the *installed* package, a GraphKit-backed data plane in IHA behind a default-off flag, reads and a reverted mutating write through it, and a full credential-generation rollover ending in the old generation's revocation. Catalog coverage of IHA's declared surface is 27 of 27 at the API version it actually calls. The 2026-08-15 cutover record preserved two operator actions because active customer repointing would have required the legacy fallback to remain. Current operator status on 2026-08-29 is that no legacy or customer-tenant consumer uses these paths, so that historical contingency is not a `0.3.0` release blocker; the repository does not independently inventory external consumers. Purging deleted directory data remains policy-controlled housekeeping rather than package work. Read `docs/cutover/2026-08-15-phase5-cutover.md` before revisiting the historical cutover. diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 new file mode 100644 index 0000000..0b7b06c --- /dev/null +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -0,0 +1,193 @@ +<# + .SYNOPSIS + Seals a tested GraphKit package, built tree, NUnit result, and source revision. + + .DESCRIPTION + This script never builds or publishes. Run pack first, test second, then create the + proof. Every regular file in the built module must be present byte-for-byte in the + package; the package container, complete file inventory, NUnit result, and clean tracked + source revision are recorded together. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $PackagePath, + [Parameter(Mandatory)] [string] $TestResultPath, + [Parameter(Mandatory)] [string] $OutputPath, + [string] $RepositoryRoot = (Split-Path $PSScriptRoot -Parent), + [ValidateRange(1, [int]::MaxValue)] [int] $MinimumTests = 789 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Get-LowerFileHash { + param([Parameter(Mandatory)] [string] $Path) + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Get-LowerStreamHash { + param([Parameter(Mandatory)] [System.IO.Stream] $Stream) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { return [Convert]::ToHexString($sha.ComputeHash($Stream)).ToLowerInvariant() } + finally { $sha.Dispose() } +} + +function Assert-RegularFile { + param([Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $Label) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (-not $item.PSIsContainer -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) { return $item } + throw "$Label '$Path' must be a regular file and must not be a symlink or reparse point." +} + +function Assert-SafeRelativePath { + param([Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $Kind) + if ([string]::IsNullOrWhiteSpace($Path) -or $Path.Contains('\') -or $Path.StartsWith('/') -or + $Path -match '^[A-Za-z]:' -or $Path -match '(^|/)\.\.?(/|$)') { + throw "Unsafe $Kind path '$Path'." + } +} + +function Test-NuGetMetadataPath { + param([Parameter(Mandatory)] [string] $Path) + return $Path -in @('_rels/.rels', 'GraphKit.nuspec', '[Content_Types].xml') -or + $Path -match '^package/services/metadata/core-properties/[^/]+\.psmdcp$' +} + +$repo = (Resolve-Path -LiteralPath $RepositoryRoot).ProviderPath +$package = Assert-RegularFile -Path (Resolve-Path -LiteralPath $PackagePath).ProviderPath -Label 'Package' +$testResult = Assert-RegularFile -Path (Resolve-Path -LiteralPath $TestResultPath).ProviderPath -Label 'Test result' + +$trackedStatus = (& git -C $repo status --porcelain --untracked-files=no 2>&1 | Out-String).Trim() +if ($LASTEXITCODE -ne 0) { throw "Could not inspect Git status for '$repo': $trackedStatus" } +if (-not [string]::IsNullOrEmpty($trackedStatus)) { + throw 'Tracked source is dirty. Commit the exact tested source before creating a release proof.' +} +$sourceRevision = (& git -C $repo rev-parse HEAD 2>&1 | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $sourceRevision -notmatch '^[0-9a-f]{40}$') { + throw "Could not resolve a 40-character source revision for '$repo'." +} + +if ($package.Name -notmatch '^GraphKit\.(?\d+\.\d+\.\d+)\.nupkg$') { + throw "Package '$($package.Name)' must be named GraphKit..nupkg." +} +$version = $Matches.version +if ($version -ne '0.3.1') { throw "This maintenance proof requires GraphKit 0.3.1; got '$version'." } + +$moduleRoot = Join-Path $repo "output/module/GraphKit/$version" +if (-not (Test-Path -LiteralPath $moduleRoot -PathType Container)) { + throw "Built module '$moduleRoot' does not exist. Run pack before test and proof creation." +} +$manifest = Import-PowerShellDataFile (Join-Path $moduleRoot 'GraphKit.psd1') +if ([string] $manifest.ModuleVersion -ne $version) { + throw "Built manifest version '$($manifest.ModuleVersion)' does not match package version '$version'." +} + +[xml] $resultDocument = Get-Content -LiteralPath $testResult.FullName -Raw +$resultRoot = $resultDocument.SelectSingleNode('/test-results') +if ($null -eq $resultRoot) { throw "Test result '$($testResult.FullName)' has no /test-results root." } +$counts = [ordered]@{ + total = [int] $resultRoot.GetAttribute('total') + failures = [int] $resultRoot.GetAttribute('failures') + errors = [int] $resultRoot.GetAttribute('errors') + skipped = [int] $resultRoot.GetAttribute('skipped') + inconclusive = [int] $resultRoot.GetAttribute('inconclusive') + notRun = [int] $resultRoot.GetAttribute('not-run') +} +if ($counts.total -lt $MinimumTests) { throw "Test result has $($counts.total) tests; minimum is $MinimumTests." } +foreach ($zeroField in @('failures', 'errors', 'skipped', 'inconclusive', 'notRun')) { + if ($counts[$zeroField] -ne 0) { throw "Test result is not releasable: $zeroField=$($counts[$zeroField])." } +} +$resultName = [string] $resultRoot.GetAttribute('name') +if ($testResult.Name -notmatch [regex]::Escape($version) -and $resultName -notmatch [regex]::Escape($version)) { + throw "Test result '$($testResult.Name)' does not identify GraphKit $version." +} + +$moduleFiles = [System.Collections.Generic.List[object]]::new() +$pathKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($file in Get-ChildItem -LiteralPath $moduleRoot -File -Recurse | Sort-Object FullName) { + $null = Assert-RegularFile -Path $file.FullName -Label 'Built module file' + $relative = [System.IO.Path]::GetRelativePath($moduleRoot, $file.FullName).Replace('\', '/') + Assert-SafeRelativePath -Path $relative -Kind 'built module' + if (-not $pathKeys.Add($relative)) { throw "Duplicate built module path '$relative' (case-insensitive)." } + $moduleFiles.Add([ordered]@{ + path = $relative + sizeBytes = [int64] $file.Length + sha256 = Get-LowerFileHash -Path $file.FullName + }) +} +if ($moduleFiles.Count -eq 0) { throw 'Built module contains no files.' } + +$archivePaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$matchedModule = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) +$archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) +try { + foreach ($entry in $archive.Entries) { + if ([string]::IsNullOrEmpty($entry.Name)) { continue } + $path = $entry.FullName + Assert-SafeRelativePath -Path $path -Kind 'archive' + if (-not $archivePaths.Add($path)) { throw "Duplicate archive path '$path' (case-insensitive)." } + + $expected = $moduleFiles | Where-Object { $_.path -ceq $path } | Select-Object -First 1 + if ($null -eq $expected) { + if (-not (Test-NuGetMetadataPath -Path $path)) { throw "Unlisted archive entry '$path'." } + continue + } + + $stream = $entry.Open() + try { $actualHash = Get-LowerStreamHash -Stream $stream } finally { $stream.Dispose() } + if ($actualHash -cne $expected.sha256 -or [int64] $entry.Length -ne $expected.sizeBytes) { + throw "Archive file digest mismatch for '$path'." + } + $null = $matchedModule.Add($path) + } +} +finally { $archive.Dispose() } + +$missing = @($moduleFiles.path | Where-Object { -not $matchedModule.Contains($_) }) +if ($missing.Count -gt 0) { throw "Archive file set mismatch; missing: $($missing -join ', ')." } + +$proof = [ordered]@{ + schemaVersion = 1 + sourceRevision = $sourceRevision + module = [ordered]@{ + name = 'GraphKit' + version = $version + files = @($moduleFiles) + } + package = [ordered]@{ + name = $package.Name + sizeBytes = [int64] $package.Length + sha256 = Get-LowerFileHash -Path $package.FullName + } + testRun = [ordered]@{ + nunit = [ordered]@{ + name = $testResult.Name + sha256 = Get-LowerFileHash -Path $testResult.FullName + total = $counts.total + failures = $counts.failures + errors = $counts.errors + skipped = $counts.skipped + inconclusive = $counts.inconclusive + notRun = $counts.notRun + } + } +} + +$outputFullPath = [System.IO.Path]::GetFullPath($OutputPath, (Get-Location).ProviderPath) +$outputDirectory = Split-Path -Parent $outputFullPath +if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) { + $null = New-Item -ItemType Directory -Path $outputDirectory -Force +} +$temporaryPath = Join-Path $outputDirectory ('.{0}.{1}.tmp' -f (Split-Path $outputFullPath -Leaf), [guid]::NewGuid().ToString('N')) +try { + $stream = [System.IO.FileStream]::new($temporaryPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) + $writer = [System.IO.StreamWriter]::new($stream, [System.Text.UTF8Encoding]::new($false)) + try { $writer.Write(($proof | ConvertTo-Json -Depth 12)) } finally { $writer.Dispose() } + [System.IO.File]::Move($temporaryPath, $outputFullPath, $true) +} +finally { + if (Test-Path -LiteralPath $temporaryPath) { Remove-Item -LiteralPath $temporaryPath -Force } +} + +[pscustomobject] $proof diff --git a/scripts/Publish-GraphKitPackage.ps1 b/scripts/Publish-GraphKitPackage.ps1 index d218ee2..a548edb 100644 --- a/scripts/Publish-GraphKitPackage.ps1 +++ b/scripts/Publish-GraphKitPackage.ps1 @@ -1,73 +1,20 @@ <# .SYNOPSIS - Publishes an already-built, already-tested GraphKit package to a private versioned - channel and writes the pin record that consumers install against. + Verifies a sealed GraphKit package and optionally publishes it to a versioned channel. .DESCRIPTION - Phase 5 step 3 of the cutover. Three rules from the design are enforced here rather - than left to operator discipline: - - **Publish only the already-tested artifact.** This script never builds. It takes a - .nupkg that already exists and refuses to run unless a passing test result is - presented for the same version. Rebuilding in the publish step would ship bits that - nothing tested, and the difference is invisible afterwards. - - **The channel must exist before anything depends on it.** Publishing writes a pin - record - name, version, sha256, channel, source - which is the artifact consumers - install against. An install that cannot name the exact bytes it expects is not a pin. - - **Versioned and immutable.** Re-publishing a version that already exists in the - channel is refused unless -Force is given, because silently replacing a version - under a pin makes the pin a lie. - - Two channels are implemented: - - - FileSystem: a directory acting as a PSResourceGet repository. Works today with no - external account, and is what the machinery is proven against. - - GitHubRelease: uploads the .nupkg as an asset on a release of a private GitHub - repository. Requires the gh CLI, authenticated, with the repository already - created. This performs an outward publication and is therefore never run without - an explicit -Confirm decision. - - .PARAMETER PackagePath - Path to the already-built .nupkg. - - .PARAMETER Channel - FileSystem or GitHubRelease. - - .PARAMETER Destination - For FileSystem, the repository directory. For GitHubRelease, owner/repo. - - .PARAMETER TestResultPath - NUnit result file proving this build passed. Required unless -SkipTestProof is given, - which exists only for a channel dry run and says so loudly. - - .PARAMETER PinPath - Where to write the pin record. Defaults to ./output/graphkit.pin.json. - - .EXAMPLE - ./scripts/Publish-GraphKitPackage.ps1 -PackagePath output/GraphKit.0.0.1.nupkg ` - -Channel FileSystem -Destination ~/graphkit-channel ` - -TestResultPath output/testResults/NUnitXml_GraphKit_v0.0.1.MacOS.PSv.7.6.5.xml + Report-only by default. The script never builds and has no proof bypass. It publishes only + when -Publish is named and ShouldProcess authorizes the exact destination, and it operates + only on a verifier-owned snapshot of the package. #> [CmdletBinding(SupportsShouldProcess)] param( - [Parameter(Mandatory)] - [string] $PackagePath, - - [Parameter(Mandatory)] - [ValidateSet('FileSystem', 'GitHubRelease')] - [string] $Channel, - - [Parameter(Mandatory)] - [string] $Destination, - - [string] $TestResultPath, - - [switch] $SkipTestProof, - + [Parameter(Mandatory)] [string] $PackagePath, + [Parameter(Mandatory)] [string] $ProofPath, + [Parameter(Mandatory)] [ValidateSet('FileSystem', 'GitHubRelease')] [string] $Channel, + [Parameter(Mandatory)] [string] $Destination, + [switch] $Publish, [string] $PinPath, - [switch] $Force ) @@ -75,186 +22,123 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 $repoRoot = Split-Path $PSScriptRoot -Parent - -if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { - throw "Package '$PackagePath' does not exist. Build it first with ./build.ps1 -Tasks pack; this script deliberately does not build, so that what ships is what was tested." -} - -$package = Get-Item -LiteralPath $PackagePath -if ($package.Extension -ne '.nupkg') { - throw "Package '$PackagePath' is not a .nupkg." -} - -# Version comes from the file name, which is what the channel will key on. -if ($package.BaseName -notmatch '^(?.+?)\.(?\d+\.\d+\.\d+(?:-[A-Za-z0-9.\-]+)?)$') { - throw "Cannot parse a module name and version from '$($package.Name)'. Expected ..nupkg." -} -$moduleName = $Matches['name'] -$moduleVersion = $Matches['version'] - -if ($moduleName -ne 'GraphKit') { - throw "Package '$($package.Name)' is '$moduleName', not GraphKit." -} - -# --- Proof that these exact bits passed their tests ------------------------------------- -if ($SkipTestProof) { - Write-Warning 'PUBLISHING WITHOUT TEST PROOF. -SkipTestProof was given, so this package is NOT known to have passed its suite. Do not use this for a channel that anything installs from.' -} -else { - if ([string]::IsNullOrWhiteSpace($TestResultPath)) { - throw 'A -TestResultPath is required: the contract is to publish only the already-tested artifact. Pass the NUnit result for this build, or pass -SkipTestProof and accept that the package is unverified.' - } - if (-not (Test-Path -LiteralPath $TestResultPath -PathType Leaf)) { - throw "Test result '$TestResultPath' does not exist." - } - - $gate = Join-Path $repoRoot 'tests/QA/Assert-GateResult.ps1' - & pwsh -NoProfile -File $gate -ResultPath $TestResultPath -MinimumTests 777 -AllowedSkips 0 | Write-Verbose - if ($LASTEXITCODE -ne 0) { - throw "The supplied test result did not pass the whole-result gate, so this package must not be published. Run: pwsh -File tests/QA/Assert-GateResult.ps1 -ResultPath '$TestResultPath' -MinimumTests 777" - } - - # The result must belong to this version, or it proves nothing about these bits. - [xml] $resultDoc = Get-Content -LiteralPath $TestResultPath -Raw - $resultName = [string] $resultDoc.SelectSingleNode('/test-results').GetAttribute('name') - if ($TestResultPath -notmatch [regex]::Escape($moduleVersion) -and $resultName -notmatch [regex]::Escape($moduleVersion)) { - throw "Test result '$TestResultPath' does not reference version $moduleVersion. Publishing a package against another build's result would make the proof meaningless." - } - - # Matching version numbers are not proof that these bytes are the tested bytes: the - # 'pack' task begins with Clean, so a build/test/pack ordering silently rebuilds the - # module after the suite ran and ships something no test ever saw. Compare the psm1 - # inside the package against the built module the tests actually imported. This turns - # "publish only the already-tested artifact" from a procedural rule into a checked one. - $builtPsm1 = Join-Path $repoRoot "output/module/GraphKit/$moduleVersion/GraphKit.psm1" - if (-not (Test-Path -LiteralPath $builtPsm1 -PathType Leaf)) { - throw "The built module at '$builtPsm1' is gone, so this package cannot be tied back to the tested bits. Run ./build.ps1 -Tasks pack FIRST and ./build.ps1 -Tasks test SECOND - test does not clean, pack does." - } - - Add-Type -AssemblyName System.IO.Compression.FileSystem - $archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) - try { - $entry = $archive.Entries | Where-Object { $_.FullName -eq 'GraphKit.psm1' } | Select-Object -First 1 - if ($null -eq $entry) { throw "Package '$($package.Name)' contains no GraphKit.psm1." } - - $stream = $entry.Open() - try { - $sha = [System.Security.Cryptography.SHA256]::Create() - $packagedHash = [System.BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-', '') +$verifier = Join-Path $PSScriptRoot 'Test-GraphKitReleaseProof.ps1' +$snapshotRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("graphkit-publish-{0}" -f [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $snapshotRoot + +try { + $packageSnapshot = Join-Path $snapshotRoot (Split-Path $PackagePath -Leaf) + $proofSnapshot = Join-Path $snapshotRoot 'tested-release-proof.json' + $verified = & $verifier -PackagePath $PackagePath -ProofPath $ProofPath -RepositoryRoot $repoRoot ` + -PackageSnapshotPath $packageSnapshot -ProofSnapshotPath $proofSnapshot + + Write-Host '' + Write-Host " VERIFIED: GraphKit $($verified.Version)" -ForegroundColor Green + Write-Host " package : $([System.IO.Path]::GetFileName($verified.PackageSnapshotPath)) ($($verified.PackageSizeBytes) bytes)" -ForegroundColor Cyan + Write-Host " sha256 : $($verified.PackageSha256)" -ForegroundColor Cyan + Write-Host " tests : $($verified.TestCount)" -ForegroundColor Cyan + Write-Host " files : $($verified.ShippedFileCount)" -ForegroundColor Cyan + Write-Host " source : $($verified.SourceRevision)" -ForegroundColor Cyan + Write-Host " channel : $Channel -> $Destination" -ForegroundColor Cyan + Write-Host '' + + if (-not $Publish) { + Write-Host ' Report-only verification complete. Nothing was published.' -ForegroundColor DarkGray + return [pscustomobject]@{ + Version = $verified.Version; PackageSha256 = $verified.PackageSha256 + PackageSizeBytes = $verified.PackageSizeBytes; TestCount = $verified.TestCount + ShippedFileCount = $verified.ShippedFileCount; SourceRevision = $verified.SourceRevision + Published = $false; Channel = $Channel; Destination = $Destination; PinPath = $null } - finally { $stream.Dispose() } - } - finally { $archive.Dispose() } - - $testedHash = (Get-FileHash -LiteralPath $builtPsm1 -Algorithm SHA256).Hash - if (-not [string]::Equals($packagedHash, $testedHash, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "The GraphKit.psm1 inside '$($package.Name)' ($packagedHash) is NOT the one the tests ran against ($testedHash). The module was rebuilt between testing and packaging, so this package is unverified. Run ./build.ps1 -Tasks pack, then ./build.ps1 -Tasks test, then publish." } - Write-Verbose "Packaged GraphKit.psm1 matches the tested build ($testedHash)." -} - -$hash = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash - -Write-Host '' -Write-Host " package : $($package.Name) ($($package.Length) bytes)" -ForegroundColor Cyan -Write-Host " version : $moduleVersion" -ForegroundColor Cyan -Write-Host " sha256 : $hash" -ForegroundColor Cyan -Write-Host " channel : $Channel -> $Destination" -ForegroundColor Cyan -Write-Host '' - -# --- Publish ---------------------------------------------------------------------------- -$publishedSource = $null -switch ($Channel) { - 'FileSystem' { - $target = Join-Path $Destination $package.Name + $snapshot = Get-Item -LiteralPath $verified.PackageSnapshotPath + $publishedSource = $null + $published = $false + + switch ($Channel) { + 'FileSystem' { + $target = Join-Path $Destination $snapshot.Name + if (Test-Path -LiteralPath $target -PathType Leaf) { + $existingHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash.ToLowerInvariant() + if ($existingHash -ceq $verified.PackageSha256) { + Write-Host ' Already published with identical bytes; nothing to replace.' -ForegroundColor Green + $published = $true + } + elseif (-not $Force) { + throw "Version $($verified.Version) already exists in '$Destination' with DIFFERENT bytes. Publish a new version, or use -Force only for an explicitly authorized recovery." + } + } - if ((Test-Path -LiteralPath $target -PathType Leaf) -and -not $Force) { - $existingHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash - if ($existingHash -eq $hash) { - Write-Host ' Already published with identical bytes; nothing to do.' -ForegroundColor Green + if (-not $published -and $PSCmdlet.ShouldProcess($target, 'Publish verified package to file-system channel')) { + if (-not (Test-Path -LiteralPath $Destination -PathType Container)) { + $null = New-Item -ItemType Directory -Path $Destination -Force + } + Copy-Item -LiteralPath $verified.PackageSnapshotPath -Destination $target -Force + $published = $true } - else { - throw "Version $moduleVersion already exists in '$Destination' with DIFFERENT bytes (channel $existingHash vs local $hash). Replacing it would make every existing pin a lie. Publish a new version, or pass -Force if you are certain." + $publishedSource = if (Test-Path -LiteralPath $Destination -PathType Container) { + (Resolve-Path -LiteralPath $Destination).ProviderPath } } - elseif ($PSCmdlet.ShouldProcess($target, 'Publish package to file-system channel')) { - if (-not (Test-Path -LiteralPath $Destination -PathType Container)) { - $null = New-Item -ItemType Directory -Path $Destination -Force + + 'GitHubRelease' { + if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { throw 'The gh CLI is required for GitHubRelease.' } + if ($Destination -notmatch '^[^/]+/[^/]+$') { throw "GitHubRelease destination must be owner/repo; got '$Destination'." } + $tag = "v$($verified.Version)" + if ($PSCmdlet.ShouldProcess("$Destination release $tag", 'Upload verified package to GitHub release')) { + & gh release view $tag --repo $Destination --json tagName *> $null + if ($LASTEXITCODE -ne 0) { + & gh release create $tag --repo $Destination --title "GraphKit $($verified.Version)" ` + --notes "GraphKit $($verified.Version). sha256 $($verified.PackageSha256)" --prerelease=false + if ($LASTEXITCODE -ne 0) { throw "gh release create failed for $Destination $tag." } + } + elseif (-not $Force) { throw "Release $tag already exists in $Destination." } + + & gh release upload $tag $verified.PackageSnapshotPath --repo $Destination --clobber:$Force + if ($LASTEXITCODE -ne 0) { throw "gh release upload failed for $Destination $tag." } + $published = $true } - Copy-Item -LiteralPath $package.FullName -Destination $target -Force - Write-Host " Published to $target" -ForegroundColor Green + $publishedSource = "https://github.com/$Destination/releases/tag/$tag" } - - $publishedSource = (Resolve-Path -LiteralPath $Destination).Path } - 'GitHubRelease' { - if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - throw 'The gh CLI is required for the GitHubRelease channel and was not found on PATH.' - } - if ($Destination -notmatch '^[^/]+/[^/]+$') { - throw "For -Channel GitHubRelease, -Destination must be owner/repo; got '$Destination'." + if (-not $published) { + Write-Host ' Nothing was published because ShouldProcess did not authorize the operation.' -ForegroundColor DarkGray + return [pscustomobject]@{ + Version = $verified.Version; PackageSha256 = $verified.PackageSha256 + PackageSizeBytes = $verified.PackageSizeBytes; TestCount = $verified.TestCount + ShippedFileCount = $verified.ShippedFileCount; SourceRevision = $verified.SourceRevision + Published = $false; Channel = $Channel; Destination = $Destination; PinPath = $null } - - $tag = "v$moduleVersion" - - # This is an outward publication: it sends the package to GitHub. It only happens - # under an explicit ShouldProcess decision, never as a side effect. - if ($PSCmdlet.ShouldProcess("$Destination release $tag", 'Upload package asset to GitHub release')) { - $exists = (& gh release view $tag --repo $Destination --json tagName 2>$null) - if ($LASTEXITCODE -ne 0) { - & gh release create $tag --repo $Destination --title "GraphKit $moduleVersion" --notes "GraphKit $moduleVersion. sha256 $hash" --prerelease=false - if ($LASTEXITCODE -ne 0) { throw "gh release create failed for $Destination $tag." } - } - elseif (-not $Force) { - throw "Release $tag already exists in $Destination. Publish a new version rather than replacing one under an existing pin, or pass -Force." - } - - & gh release upload $tag $package.FullName --repo $Destination --clobber:$Force - if ($LASTEXITCODE -ne 0) { throw "gh release upload failed for $Destination $tag." } - Write-Host " Uploaded $($package.Name) to $Destination release $tag" -ForegroundColor Green - } - - $publishedSource = "https://github.com/$Destination/releases/tag/$tag" } -} -# --- Pin record ------------------------------------------------------------------------- -if ([string]::IsNullOrWhiteSpace($PinPath)) { - # NOT output/: the 'pack' task begins with Clean, so a pin written there is deleted by - # the next build - and the pin is the contract a consumer installs against, so losing it - # silently breaks every downstream host. For a file-system channel the pin belongs beside - # the package it describes, which also means it travels with the channel automatically. - $PinPath = if ($Channel -eq 'FileSystem') { - Join-Path $Destination 'graphkit.pin.json' + if ([string]::IsNullOrWhiteSpace($PinPath)) { + $PinPath = if ($Channel -eq 'FileSystem') { Join-Path $Destination 'graphkit.pin.json' } else { Join-Path $repoRoot 'graphkit.pin.json' } } - else { - Join-Path $repoRoot 'graphkit.pin.json' + $pin = [ordered]@{ + schema = 'graphkit-pin/2'; moduleName = 'GraphKit'; version = $verified.Version + sha256 = $verified.PackageSha256; sizeBytes = $verified.PackageSizeBytes + channel = $Channel; source = $publishedSource; packageName = $snapshot.Name + sourceRevision = $verified.SourceRevision; testCount = $verified.TestCount + releaseProofSha256 = (Get-FileHash -LiteralPath $verified.ProofSnapshotPath -Algorithm SHA256).Hash.ToLowerInvariant() + publishedUtc = [datetime]::UtcNow.ToString('o') + } + if ($PSCmdlet.ShouldProcess($PinPath, 'Write verified package pin record')) { + $pinDirectory = Split-Path -Parent $PinPath + if (-not [string]::IsNullOrWhiteSpace($pinDirectory) -and -not (Test-Path -LiteralPath $pinDirectory -PathType Container)) { + $null = New-Item -ItemType Directory -Path $pinDirectory -Force + } + $pin | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $PinPath -Encoding utf8 } -} - -$pin = [ordered]@{ - schema = 'graphkit-pin/1' - moduleName = $moduleName - version = $moduleVersion - sha256 = $hash - sizeBytes = $package.Length - channel = $Channel - source = $publishedSource - packageName = $package.Name - testProof = if ($SkipTestProof) { 'NONE - published without test proof' } else { (Resolve-Path -LiteralPath $TestResultPath).Path } - publishedUtc = [datetime]::UtcNow.ToString('o') -} -if ($PSCmdlet.ShouldProcess($PinPath, 'Write pin record')) { - $pinDir = Split-Path -Parent $PinPath - if (-not [string]::IsNullOrEmpty($pinDir) -and -not (Test-Path -LiteralPath $pinDir -PathType Container)) { - $null = New-Item -ItemType Directory -Path $pinDir -Force + [pscustomobject]@{ + Version = $verified.Version; PackageSha256 = $verified.PackageSha256 + PackageSizeBytes = $verified.PackageSizeBytes; TestCount = $verified.TestCount + ShippedFileCount = $verified.ShippedFileCount; SourceRevision = $verified.SourceRevision + Published = $true; Channel = $Channel; Destination = $Destination; PinPath = $PinPath } - ($pin | ConvertTo-Json -Depth 5) | Set-Content -LiteralPath $PinPath -Encoding utf8 - Write-Host " Pin record written to $PinPath" -ForegroundColor Green } - -Write-Host '' -[pscustomobject] $pin +finally { + if (Test-Path -LiteralPath $snapshotRoot) { Remove-Item -LiteralPath $snapshotRoot -Recurse -Force } +} diff --git a/scripts/Publish-GraphKitToGallery.ps1 b/scripts/Publish-GraphKitToGallery.ps1 index 3a57af6..2f77725 100644 --- a/scripts/Publish-GraphKitToGallery.ps1 +++ b/scripts/Publish-GraphKitToGallery.ps1 @@ -1,260 +1,126 @@ <# .SYNOPSIS - Pre-flight checks, then publishes GraphKit to the PowerShell Gallery. + Verifies a sealed GraphKit package and optionally publishes it to PSGallery. .DESCRIPTION - Publishing to the public gallery is effectively permanent: a version can be unlisted - but never deleted, and the module name is claimed for good. Everything this script - does before the push exists because of that. - - The API key is never taken as a plain parameter and never appears on a command line. - It is prompted for as a SecureString, so it does not enter shell history, does not sit - in a variable another process can read, and is converted to plain text only at the - moment it is handed to Publish-PSResource. - - Pre-flight refuses to publish when: - - - the package does not exist, or was not built from the current manifest version - - the manifest fails Test-ModuleManifest - - required gallery metadata is missing (Author, Description, Tags, LicenseUri) - - the package contains identifiers that must not become public - tenant ids, client - ids, certificate thumbprints, local paths, or internal project names - - that exact version already exists on the gallery - - the test suite result for this version is absent or failing - - The scan is the important one. Everything else can be fixed in the next version; a - tenant id in a published package cannot be taken back. - - .PARAMETER PackagePath - The .nupkg to publish. Defaults to the current version in output/. - - .PARAMETER WhatIfOnly - Run every pre-flight check and stop, without prompting for a key or publishing. - - .EXAMPLE - ./scripts/Publish-GraphKitToGallery.ps1 -WhatIfOnly - - Runs the full pre-flight and publishes nothing. - - .EXAMPLE - ./scripts/Publish-GraphKitToGallery.ps1 - - Runs pre-flight, prompts for the API key, publishes. + Report-only by default. Publication requires the named -Publish switch, normal + ShouldProcess authorization, and a securely entered API key. The published input is a + verifier-owned snapshot, never the caller-owned path. #> [CmdletBinding(SupportsShouldProcess)] param( [string] $PackagePath, - - [switch] $WhatIfOnly + [Parameter(Mandatory)] [string] $ProofPath, + [switch] $Publish ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3.0 - $repoRoot = Split-Path $PSScriptRoot -Parent -$manifestPath = Join-Path $repoRoot 'source/GraphKit.psd1' -$manifest = Import-PowerShellDataFile $manifestPath -$version = $manifest.ModuleVersion - -if ([string]::IsNullOrWhiteSpace($PackagePath)) { - $PackagePath = Join-Path $repoRoot "output/GraphKit.$version.nupkg" -} - -$failures = [System.Collections.Generic.List[string]]::new() -function Test-Gate { - param([string] $Name, [bool] $Passed, [string] $Detail = '') - $mark = if ($Passed) { '[+]' } else { '[-]' } - $colour = if ($Passed) { 'Green' } else { 'Red' } - Write-Host (" {0} {1,-46} {2}" -f $mark, $Name, $Detail) -ForegroundColor $colour - if (-not $Passed) { $failures.Add($Name) } -} - -Write-Host '' -Write-Host " Pre-flight for GraphKit $version -> PowerShell Gallery" -ForegroundColor Cyan -Write-Host '' +$manifest = Import-PowerShellDataFile (Join-Path $repoRoot 'source/GraphKit.psd1') +$version = [string] $manifest.ModuleVersion +if ([string]::IsNullOrWhiteSpace($PackagePath)) { $PackagePath = Join-Path $repoRoot "output/GraphKit.$version.nupkg" } -# --- package exists and matches the manifest version ------------------------------------ -$packageExists = Test-Path -LiteralPath $PackagePath -PathType Leaf -Test-Gate 'package exists' $packageExists $(if ($packageExists) { Split-Path $PackagePath -Leaf } else { $PackagePath }) - -if ($packageExists) { - Test-Gate 'package version matches the manifest' ((Split-Path $PackagePath -Leaf) -eq "GraphKit.$version.nupkg") "manifest says $version" -} - -# --- manifest validity and gallery metadata ---------------------------------------------- -$builtManifest = Join-Path $repoRoot "output/module/GraphKit/$version/GraphKit.psd1" -if (Test-Path -LiteralPath $builtManifest) { - try { - $null = Test-ModuleManifest -Path $builtManifest -ErrorAction Stop - Test-Gate 'Test-ModuleManifest passes' $true +$snapshotRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("graphkit-gallery-{0}" -f [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $snapshotRoot +try { + $verifier = Join-Path $PSScriptRoot 'Test-GraphKitReleaseProof.ps1' + $verified = & $verifier -PackagePath $PackagePath -ProofPath $ProofPath -RepositoryRoot $repoRoot ` + -PackageSnapshotPath (Join-Path $snapshotRoot (Split-Path $PackagePath -Leaf)) ` + -ProofSnapshotPath (Join-Path $snapshotRoot 'tested-release-proof.json') + + if ($verified.Version -cne $version) { throw "Verified package version '$($verified.Version)' does not match source manifest '$version'." } + $builtManifestPath = Join-Path $repoRoot "output/module/GraphKit/$version/GraphKit.psd1" + $null = Test-ModuleManifest -Path $builtManifestPath -ErrorAction Stop + $psData = $manifest.PrivateData.PSData + foreach ($required in @('Author', 'Description')) { + if ([string]::IsNullOrWhiteSpace([string] $manifest[$required])) { throw "Manifest $required is required for PSGallery." } } - catch { - Test-Gate 'Test-ModuleManifest passes' $false $_.Exception.Message + if (@($psData.Tags).Count -eq 0 -or [string]::IsNullOrWhiteSpace([string] $psData.LicenseUri) -or + [string]::IsNullOrWhiteSpace([string] $psData.ReleaseNotes)) { + throw 'Manifest Tags, LicenseUri, and ReleaseNotes are required for PSGallery.' } -} -else { - Test-Gate 'built module present' $false $builtManifest -} - -$psData = $manifest.PrivateData.PSData -Test-Gate 'Author set' (-not [string]::IsNullOrWhiteSpace($manifest.Author)) $manifest.Author -Test-Gate 'Description set' (-not [string]::IsNullOrWhiteSpace($manifest.Description)) -Test-Gate 'Tags set' ($psData.ContainsKey('Tags') -and @($psData.Tags).Count -gt 0) "$(@($psData.Tags).Count) tags" -Test-Gate 'LicenseUri set' ($psData.ContainsKey('LicenseUri') -and -not [string]::IsNullOrWhiteSpace($psData.LicenseUri)) -Test-Gate 'LICENSE file present' (Test-Path -LiteralPath (Join-Path $repoRoot 'LICENSE')) -Test-Gate 'ReleaseNotes set' ($psData.ContainsKey('ReleaseNotes') -and -not [string]::IsNullOrWhiteSpace($psData.ReleaseNotes)) -# --- the scan that cannot be undone after the fact --------------------------------------- -if ($packageExists) { Add-Type -AssemblyName System.IO.Compression.FileSystem - $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) - try { - $findings = [System.Collections.Generic.List[string]]::new() - $patterns = @{ - 'GUID that is not a well-known Microsoft id' = '\b(?!00000000-0000-0000-0000-00000000000[01]\b)(?!00000003-0000-0000-c000-000000000000\b)(?!' + [regex]::Escape($manifest.GUID) + '\b)[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b' - 'certificate thumbprint' = '\b[0-9A-Fa-f]{40}\b' - 'local user path' = '/Users/[A-Za-z0-9._-]+|C:\\Users\\[A-Za-z0-9._-]+' - 'internal project name' = '(?i)\bivy24\b|\bIntuneHealthAutomation\b' - } - - # Customer names are matched by HASH rather than by literal, because this script lives in - # a public repository: a regex spelling out a customer name would publish the name it - # exists to keep out. SHA-256 of the lowercased token, first 32 hex chars. To add one, - # hash it the same way and give it a non-identifying label - never the name itself. - $secretTokenHashes = @{ - '5cad5cdbf022740cbfc976f9836ac89d' = 'customer name (A)' - 'e03427b1afcd1e84a97ed1f2241466cb' = 'internal workspace tenant' - '9a08498936078c81ec926fedbce5e7c9' = 'customer name (A, short form)' - '6ca05670c4afd49e806f7cddbab83b00' = 'lab tenant id' - } - function Get-TokenDigest { - param([string] $Token) - $bytes = [System.Text.Encoding]::UTF8.GetBytes($Token.ToLowerInvariant()) - return [System.BitConverter]::ToString( - [System.Security.Cryptography.SHA256]::HashData($bytes) - ).Replace('-', '').ToLowerInvariant().Substring(0, 32) - } + $findings = [System.Collections.Generic.List[string]]::new() + $patterns = @{ + 'GUID that is not a well-known Microsoft id' = '\b(?!00000000-0000-0000-0000-00000000000[01]\b)(?!00000003-0000-0000-c000-000000000000\b)(?!' + [regex]::Escape($manifest.GUID) + '\b)[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b' + 'certificate thumbprint' = '\b[0-9A-Fa-f]{40}\b' + 'local user path' = '/Users/[A-Za-z0-9._-]+|C:\\Users\\[A-Za-z0-9._-]+' + 'internal project name' = '(?i)\bivy24\b|\bIntuneHealthAutomation\b' + } + $secretTokenHashes = @{ + '5cad5cdbf022740cbfc976f9836ac89d' = 'customer name (A)' + 'e03427b1afcd1e84a97ed1f2241466cb' = 'internal workspace tenant' + '9a08498936078c81ec926fedbce5e7c9' = 'customer name (A, short form)' + '6ca05670c4afd49e806f7cddbab83b00' = 'lab tenant id' + } + function Get-TokenDigest { + param([string] $Token) + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Token.ToLowerInvariant()) + return [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant().Substring(0, 32) + } + $archive = [System.IO.Compression.ZipFile]::OpenRead($verified.PackageSnapshotPath) + try { foreach ($entry in $archive.Entries) { if ($entry.FullName -notmatch '\.(psm1|psd1|ps1|ps1xml|txt|nuspec|xml|md)$') { continue } $reader = [System.IO.StreamReader]::new($entry.Open()) try { $content = $reader.ReadToEnd() } finally { $reader.Dispose() } - foreach ($token in [regex]::Matches($content, '[A-Za-z0-9][A-Za-z0-9-]{3,}')) { $digest = Get-TokenDigest -Token $token.Value - if ($secretTokenHashes.ContainsKey($digest)) { - # Report the label, never the matched value - this output is shown on screen - # and would otherwise reintroduce the name it just caught. - $findings.Add(('{0}: internal identifier - {1}' -f $entry.FullName, $secretTokenHashes[$digest])) - } + if ($secretTokenHashes.ContainsKey($digest)) { $findings.Add("$($entry.FullName): $($secretTokenHashes[$digest])") } } - foreach ($label in $patterns.Keys) { foreach ($match in [regex]::Matches($content, $patterns[$label])) { - # Documentation placeholders like 11111111-2222-3333-4444-555555555555 are - # conventional in .EXAMPLE blocks and carry no information. A real - # identifier never has every segment built from one repeated character. if ($label -like 'GUID*') { - # @() is required: Select-Object -Unique returns a scalar for a - # segment of identical characters, and a scalar has no .Count under - # Set-StrictMode. $segments = @($match.Value -split '-') - $varied = @($segments | Where-Object { @($_.ToCharArray() | Select-Object -Unique).Count -gt 1 }) - if ($varied.Count -eq 0) { continue } + if (@($segments | Where-Object { @($_.ToCharArray() | Select-Object -Unique).Count -gt 1 }).Count -eq 0) { continue } } - $findings.Add("$label in $($entry.FullName): $($match.Value)") + $findings.Add("$label in $($entry.FullName)") } } } } finally { $archive.Dispose() } + if ($findings.Count -gt 0) { throw "Package privacy scan failed with $($findings.Count) finding(s): $($findings -join '; ')." } - Test-Gate 'package carries no identifiers that must stay private' ($findings.Count -eq 0) "$($findings.Count) finding(s)" - foreach ($finding in ($findings | Select-Object -First 12)) { - Write-Host " $finding" -ForegroundColor Yellow - } -} - -# --- the version is not already on the gallery ------------------------------------------- -try { - $existing = Find-PSResource -Name GraphKit -Repository PSGallery -ErrorAction SilentlyContinue - if ($null -eq $existing) { - Test-Gate 'name is unclaimed on PSGallery' $true 'no existing GraphKit' - } - else { - $versions = @($existing | ForEach-Object { $_.Version.ToString() }) - Test-Gate 'this version is not already published' ($version -notin $versions) "gallery has: $($versions -join ', ')" - } -} -catch { - Test-Gate 'gallery reachable' $false $_.Exception.Message -} - -# --- a passing test result for this exact version ---------------------------------------- -$resultFile = Get-ChildItem -Path (Join-Path $repoRoot 'output/testResults') -Filter "NUnit*$version*.xml" -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 -if ($null -eq $resultFile) { - Test-Gate "test result for $version present" $false 'run ./build.ps1 -Tasks pack then -Tasks test' -} -else { - [xml] $doc = Get-Content -LiteralPath $resultFile.FullName -Raw - $root = $doc.SelectSingleNode('/test-results') - $failed = [int] $root.GetAttribute('failures') - $total = [int] $root.GetAttribute('total') - Test-Gate "tests green for $version" ($failed -eq 0) "$total tests, $failed failed" -} - -Write-Host '' -if ($failures.Count -gt 0) { - Write-Host " PRE-FLIGHT FAILED - $($failures.Count) gate(s):" -ForegroundColor Red - $failures | ForEach-Object { Write-Host " $_" -ForegroundColor Red } Write-Host '' - exit 1 -} -Write-Host ' PRE-FLIGHT PASSED' -ForegroundColor Green -Write-Host '' - -if ($WhatIfOnly) { - Write-Host ' -WhatIfOnly: stopping before the key prompt. Nothing was published.' -ForegroundColor DarkGray + Write-Host " VERIFIED: GraphKit $version" -ForegroundColor Green + Write-Host " package : $([System.IO.Path]::GetFileName($verified.PackageSnapshotPath)) ($($verified.PackageSizeBytes) bytes)" -ForegroundColor Cyan + Write-Host " sha256 : $($verified.PackageSha256)" -ForegroundColor Cyan + Write-Host " tests : $($verified.TestCount)" -ForegroundColor Cyan + Write-Host " files : $($verified.ShippedFileCount)" -ForegroundColor Cyan + Write-Host " source : $($verified.SourceRevision)" -ForegroundColor Cyan Write-Host '' - exit 0 -} - -# --- publish ------------------------------------------------------------------------------ -Write-Host ' This publishes to the PUBLIC PowerShell Gallery.' -ForegroundColor Yellow -Write-Host ' A published version can be unlisted but NEVER deleted, and the name is claimed permanently.' -ForegroundColor Yellow -Write-Host '' -if (-not $PSCmdlet.ShouldProcess("GraphKit $version", 'Publish to the public PowerShell Gallery')) { - Write-Host ' Cancelled.' -ForegroundColor DarkGray - exit 0 -} + if (-not $Publish) { + Write-Host ' Report-only verification complete. Nothing was published.' -ForegroundColor DarkGray + return $verified + } -$confirmation = Read-Host " Type the version ($version) to confirm" -if ($confirmation -ne $version) { - Write-Host ' Version did not match. Nothing was published.' -ForegroundColor Red - exit 1 -} + $existing = Find-PSResource -Name GraphKit -Repository PSGallery -ErrorAction SilentlyContinue + $versions = @($existing | ForEach-Object { $_.Version.ToString() }) + if ($version -in $versions) { throw "GraphKit $version is already published to PSGallery." } -# Prompted, never a parameter: this keeps the key out of shell history and out of any -# transcript of this session. -$secureKey = Read-Host ' PSGallery API key' -AsSecureString -if ($secureKey.Length -eq 0) { - Write-Host ' No key entered. Nothing was published.' -ForegroundColor Red - exit 1 -} + if (-not $PSCmdlet.ShouldProcess("GraphKit $version", 'Publish verified package to the public PowerShell Gallery')) { + Write-Host ' Nothing was published because ShouldProcess did not authorize the operation.' -ForegroundColor DarkGray + return $verified + } -$plainKey = [System.Net.NetworkCredential]::new('', $secureKey).Password -try { - Publish-PSResource -NupkgPath $PackagePath -Repository PSGallery -ApiKey $plainKey -ErrorAction Stop - Write-Host '' + $secureKey = Read-Host 'PSGallery API key' -AsSecureString + if ($secureKey.Length -eq 0) { throw 'No PSGallery API key was entered. Nothing was published.' } + $plainKey = [System.Net.NetworkCredential]::new('', $secureKey).Password + try { + Publish-PSResource -NupkgPath $verified.PackageSnapshotPath -Repository PSGallery -ApiKey $plainKey -ErrorAction Stop + } + finally { + $plainKey = $null + [System.GC]::Collect() + } Write-Host " PUBLISHED: GraphKit $version" -ForegroundColor Green - Write-Host ' https://www.powershellgallery.com/packages/GraphKit' -ForegroundColor Green - Write-Host ' Indexing usually takes a few minutes before Install-PSResource can find it.' -ForegroundColor DarkGray - Write-Host '' + $verified } finally { - # Do not leave the key recoverable from this process. - $plainKey = $null - [System.GC]::Collect() + if (Test-Path -LiteralPath $snapshotRoot) { Remove-Item -LiteralPath $snapshotRoot -Recurse -Force } } diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 new file mode 100644 index 0000000..577a8a0 --- /dev/null +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -0,0 +1,192 @@ +<# + .SYNOPSIS + Independently verifies a GraphKit tested-release proof and optionally snapshots it. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $PackagePath, + [Parameter(Mandatory)] [string] $ProofPath, + [Parameter(Mandatory)] [string] $RepositoryRoot, + [string] $TestResultPath, + [string] $PackageSnapshotPath, + [string] $ProofSnapshotPath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Get-LowerFileHash { + param([Parameter(Mandatory)] [string] $Path) + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} +function Get-LowerStreamHash { + param([Parameter(Mandatory)] [System.IO.Stream] $Stream) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { return [Convert]::ToHexString($sha.ComputeHash($Stream)).ToLowerInvariant() } + finally { $sha.Dispose() } +} +function Assert-RegularFile { + param([Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $Label) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (-not $item.PSIsContainer -and ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) { return $item } + throw "$Label '$Path' must be a regular file and must not be a symlink or reparse point." +} +function Assert-SafeRelativePath { + param([Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $Kind) + if ([string]::IsNullOrWhiteSpace($Path) -or $Path.Contains('\') -or $Path.StartsWith('/') -or + $Path -match '^[A-Za-z]:' -or $Path -match '(^|/)\.\.?(/|$)') { + throw "Unsafe $Kind path '$Path'." + } +} +function Test-NuGetMetadataPath { + param([Parameter(Mandatory)] [string] $Path) + return $Path -in @('_rels/.rels', 'GraphKit.nuspec', '[Content_Types].xml') -or + $Path -match '^package/services/metadata/core-properties/[^/]+\.psmdcp$' +} +function Assert-ExactMembers { + param([Parameter(Mandatory)] $Object, [Parameter(Mandatory)] [string[]] $Expected, [Parameter(Mandatory)] [string] $Label) + $actual = @($Object.PSObject.Properties.Name | Sort-Object) + $wanted = @($Expected | Sort-Object) + if (($actual -join "`n") -cne ($wanted -join "`n")) { + throw "$Label members mismatch. Expected [$($wanted -join ', ')]; got [$($actual -join ', ')]." + } +} +function Copy-VerifiedFile { + param([string] $Source, [string] $Destination, [string] $ExpectedHash, [string] $Label) + if ([string]::IsNullOrWhiteSpace($Destination)) { return $null } + $full = [System.IO.Path]::GetFullPath($Destination, (Get-Location).ProviderPath) + $directory = Split-Path -Parent $full + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { $null = New-Item -ItemType Directory -Path $directory -Force } + Copy-Item -LiteralPath $Source -Destination $full -Force + $item = Assert-RegularFile -Path $full -Label $Label + if ((Get-LowerFileHash -Path $item.FullName) -cne $ExpectedHash) { throw "$Label snapshot digest mismatch after copy." } + return $item.FullName +} + +$repo = (Resolve-Path -LiteralPath $RepositoryRoot).ProviderPath +$package = Assert-RegularFile -Path (Resolve-Path -LiteralPath $PackagePath).ProviderPath -Label 'Package' +$proofFile = Assert-RegularFile -Path (Resolve-Path -LiteralPath $ProofPath).ProviderPath -Label 'Proof' +$proof = Get-Content -LiteralPath $proofFile.FullName -Raw | ConvertFrom-Json + +Assert-ExactMembers -Object $proof -Expected @('schemaVersion', 'sourceRevision', 'module', 'package', 'testRun') -Label 'Proof' +Assert-ExactMembers -Object $proof.module -Expected @('name', 'version', 'files') -Label 'Module proof' +Assert-ExactMembers -Object $proof.package -Expected @('name', 'sizeBytes', 'sha256') -Label 'Package proof' +Assert-ExactMembers -Object $proof.testRun -Expected @('nunit') -Label 'Test-run proof' +Assert-ExactMembers -Object $proof.testRun.nunit -Expected @('name', 'sha256', 'total', 'failures', 'errors', 'skipped', 'inconclusive', 'notRun') -Label 'NUnit proof' +if ([int] $proof.schemaVersion -ne 1) { throw "Unsupported proof schema '$($proof.schemaVersion)'." } +if ([string] $proof.module.name -cne 'GraphKit' -or [string] $proof.module.version -cne '0.3.1') { + throw "Proof is not for stable GraphKit 0.3.1." +} +if ([string] $proof.sourceRevision -notmatch '^[0-9a-f]{40}$') { throw 'Proof source revision is malformed.' } + +if ($package.Name -cne [string] $proof.package.name) { + throw "Package name mismatch: proof names '$($proof.package.name)', supplied '$($package.Name)'." +} + +$trackedStatus = (& git -C $repo status --porcelain --untracked-files=no 2>&1 | Out-String).Trim() +if ($LASTEXITCODE -ne 0) { throw "Could not inspect Git status for '$repo': $trackedStatus" } +if (-not [string]::IsNullOrEmpty($trackedStatus)) { throw 'Tracked source is dirty; release proof is stale.' } +$head = (& git -C $repo rev-parse HEAD 2>&1 | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $head -cne [string] $proof.sourceRevision) { + throw "Source revision mismatch: proof '$($proof.sourceRevision)', repository '$head'." +} + +$proofFiles = @($proof.module.files) +if ($proofFiles.Count -eq 0) { throw 'Proof module file list is empty.' } +$proofByPath = @{} +$proofPathKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($fileProof in $proofFiles) { + Assert-ExactMembers -Object $fileProof -Expected @('path', 'sizeBytes', 'sha256') -Label 'Module file proof' + $path = [string] $fileProof.path + Assert-SafeRelativePath -Path $path -Kind 'proof module' + if (-not $proofPathKeys.Add($path)) { throw "Duplicate proof module path '$path' (case-insensitive)." } + if ([string] $fileProof.sha256 -notmatch '^[0-9a-f]{64}$' -or [int64] $fileProof.sizeBytes -lt 0) { + throw "Malformed module file proof for '$path'." + } + $proofByPath[$path] = $fileProof +} + +$moduleRoot = Join-Path $repo "output/module/GraphKit/$($proof.module.version)" +if (-not (Test-Path -LiteralPath $moduleRoot -PathType Container)) { throw "Built module '$moduleRoot' is absent." } +$builtPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) +foreach ($file in Get-ChildItem -LiteralPath $moduleRoot -File -Recurse) { + $null = Assert-RegularFile -Path $file.FullName -Label 'Built module file' + $relative = [System.IO.Path]::GetRelativePath($moduleRoot, $file.FullName).Replace('\', '/') + Assert-SafeRelativePath -Path $relative -Kind 'built module' + if (-not $proofByPath.ContainsKey($relative)) { throw "Built file set mismatch; unlisted file '$relative'." } + $expected = $proofByPath[$relative] + if ((Get-LowerFileHash -Path $file.FullName) -cne [string] $expected.sha256 -or [int64] $file.Length -ne [int64] $expected.sizeBytes) { + throw "Built file digest mismatch for '$relative'." + } + $null = $builtPaths.Add($relative) +} +$missingBuilt = @($proofByPath.Keys | Where-Object { -not $builtPaths.Contains($_) }) +if ($missingBuilt.Count -gt 0) { throw "Built file set mismatch; missing: $($missingBuilt -join ', ')." } + +$archivePaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$matchedModule = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) +$archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) +try { + foreach ($entry in $archive.Entries) { + if ([string]::IsNullOrEmpty($entry.Name)) { continue } + $path = $entry.FullName + Assert-SafeRelativePath -Path $path -Kind 'archive' + if (-not $archivePaths.Add($path)) { throw "Duplicate archive path '$path' (case-insensitive)." } + if (-not $proofByPath.ContainsKey($path)) { + if (-not (Test-NuGetMetadataPath -Path $path)) { throw "Unlisted archive entry '$path'." } + continue + } + $expected = $proofByPath[$path] + $stream = $entry.Open() + try { $actualHash = Get-LowerStreamHash -Stream $stream } finally { $stream.Dispose() } + if ($actualHash -cne [string] $expected.sha256 -or [int64] $entry.Length -ne [int64] $expected.sizeBytes) { + throw "Archive file digest mismatch for '$path'." + } + $null = $matchedModule.Add($path) + } +} +finally { $archive.Dispose() } +$missingArchive = @($proofByPath.Keys | Where-Object { -not $matchedModule.Contains($_) }) +if ($missingArchive.Count -gt 0) { throw "Archive file set mismatch; missing: $($missingArchive -join ', ')." } + +$packageHash = Get-LowerFileHash -Path $package.FullName +if ([int64] $package.Length -ne [int64] $proof.package.sizeBytes) { throw 'Package size mismatch.' } +if ($packageHash -cne [string] $proof.package.sha256) { throw 'Package digest mismatch.' } + +if ([string]::IsNullOrWhiteSpace($TestResultPath)) { + $TestResultPath = Join-Path $repo "output/testResults/$($proof.testRun.nunit.name)" +} +$resultFile = Assert-RegularFile -Path (Resolve-Path -LiteralPath $TestResultPath).ProviderPath -Label 'Test result' +if ($resultFile.Name -cne [string] $proof.testRun.nunit.name) { throw 'Test result name mismatch.' } +if ((Get-LowerFileHash -Path $resultFile.FullName) -cne [string] $proof.testRun.nunit.sha256) { throw 'Test result digest mismatch.' } +[xml] $resultDocument = Get-Content -LiteralPath $resultFile.FullName -Raw +$resultRoot = $resultDocument.SelectSingleNode('/test-results') +if ($null -eq $resultRoot) { throw 'Test result has no /test-results root.' } +$actualCounts = @{ + total = [int] $resultRoot.GetAttribute('total'); failures = [int] $resultRoot.GetAttribute('failures') + errors = [int] $resultRoot.GetAttribute('errors'); skipped = [int] $resultRoot.GetAttribute('skipped') + inconclusive = [int] $resultRoot.GetAttribute('inconclusive'); notRun = [int] $resultRoot.GetAttribute('not-run') +} +foreach ($field in $actualCounts.Keys) { + if ($actualCounts[$field] -ne [int] $proof.testRun.nunit.$field) { throw "Test result count mismatch for '$field'." } +} +foreach ($zeroField in @('failures', 'errors', 'skipped', 'inconclusive', 'notRun')) { + if ($actualCounts[$zeroField] -ne 0) { throw "Test result is not releasable: $zeroField=$($actualCounts[$zeroField])." } +} +if ($actualCounts.total -lt 789) { throw "Test result has $($actualCounts.total) tests; release-proof minimum is 789." } + +$packageSnapshot = Copy-VerifiedFile -Source $package.FullName -Destination $PackageSnapshotPath -ExpectedHash $packageHash -Label 'Package' +$proofHash = Get-LowerFileHash -Path $proofFile.FullName +$proofSnapshot = Copy-VerifiedFile -Source $proofFile.FullName -Destination $ProofSnapshotPath -ExpectedHash $proofHash -Label 'Proof' + +[pscustomobject]@{ + Version = [string] $proof.module.version + PackageSha256 = $packageHash + PackageSizeBytes = [int64] $package.Length + TestCount = $actualCounts.total + ShippedFileCount = $proofFiles.Count + SourceRevision = $head + PackageSnapshotPath = $packageSnapshot + ProofSnapshotPath = $proofSnapshot +} diff --git a/source/GraphKit.psd1 b/source/GraphKit.psd1 index 457ba33..d7e9ce8 100644 --- a/source/GraphKit.psd1 +++ b/source/GraphKit.psd1 @@ -131,7 +131,7 @@ PrivateData = @{ ReleaseNotes = @' 0.3.1 -Stable-line maintenance bridge for TenantPulse's IntuneHealthAutomation successor path. +Stable-line maintenance bridge for TenantPulse's successor data path. This package has not been published to PSGallery. ADDED diff --git a/tests/QA/MinimumTestsRatchetSync.tests.ps1 b/tests/QA/MinimumTestsRatchetSync.tests.ps1 index bb01f63..9f8e325 100644 --- a/tests/QA/MinimumTestsRatchetSync.tests.ps1 +++ b/tests/QA/MinimumTestsRatchetSync.tests.ps1 @@ -23,16 +23,17 @@ BeforeAll { } Describe 'MinimumTests ratchet synchronization' -Tag 'QA' { - It 'keeps CI, package verification, operator guidance, and the passing fixture equal' { + It 'keeps CI, proof creation, independent verification, and the passing fixture equal' { $ci = Get-Content -LiteralPath (Join-Path $script:repoRoot '.github/workflows/ci.yml') -Raw - $publisher = Get-Content -LiteralPath (Join-Path $script:repoRoot 'scripts/Publish-GraphKitPackage.ps1') -Raw - $publishTests = Get-Content -LiteralPath (Join-Path $script:repoRoot 'tests/QA/PublishChannel.tests.ps1') -Raw + $creator = Get-Content -LiteralPath (Join-Path $script:repoRoot 'scripts/New-GraphKitTestedReleaseProof.ps1') -Raw + $verifier = Get-Content -LiteralPath (Join-Path $script:repoRoot 'scripts/Test-GraphKitReleaseProof.ps1') -Raw + $proofTests = Get-Content -LiteralPath (Join-Path $script:repoRoot 'tests/QA/ReleaseProof.tests.ps1') -Raw $values = [ordered] @{ CI = Get-SingleRatchetValue -Text $ci -Pattern '-MinimumTests\s+(\d+)\s+-AllowedSkips' -Location '.github/workflows/ci.yml' - PublishCall = Get-SingleRatchetValue -Text $publisher -Pattern '-MinimumTests\s+(\d+)\s+-AllowedSkips' -Location 'scripts/Publish-GraphKitPackage.ps1 gate call' - PublishHint = Get-SingleRatchetValue -Text $publisher -Pattern '-MinimumTests\s+(\d+)[\x27\x22]' -Location 'scripts/Publish-GraphKitPackage.ps1 error hint' - PassingFixture = Get-SingleRatchetValue -Text $publishTests -Pattern '(?s)function New-PassingResult.*?\[int\]\s+\$Total\s*=\s*(\d+)' -Location 'tests/QA/PublishChannel.tests.ps1' + Creator = Get-SingleRatchetValue -Text $creator -Pattern '\$MinimumTests\s*=\s*(\d+)' -Location 'scripts/New-GraphKitTestedReleaseProof.ps1' + Verifier = Get-SingleRatchetValue -Text $verifier -Pattern '\$actualCounts\.total\s+-lt\s+(\d+)' -Location 'scripts/Test-GraphKitReleaseProof.ps1' + PassingFixture = Get-SingleRatchetValue -Text $proofTests -Pattern ']+total="(\d+)"' -Location 'tests/QA/ReleaseProof.tests.ps1' } @($values.Values | Select-Object -Unique).Count | Should -Be 1 -Because ( diff --git a/tests/QA/PublishChannel.tests.ps1 b/tests/QA/PublishChannel.tests.ps1 index f488201..f3a0179 100644 --- a/tests/QA/PublishChannel.tests.ps1 +++ b/tests/QA/PublishChannel.tests.ps1 @@ -1,166 +1,72 @@ BeforeAll { - # PowerShell renders an exception in a box whose left gutter is a '|' on every wrapped - # line, so "with DIFFERENT bytes" arrives as "with DIFFERENT" + newline + " | bytes". - # Collapsing whitespace alone is not enough - it leaves "DIFFERENT | bytes" and the - # assertion still fails. The wrap point depends on the runner's terminal width, so this - # passed on a wide macOS terminal and failed on Linux and Windows. - # - # Strip the newline+gutter sequence FIRST (it needs the newlines), then collapse. - function ConvertTo-FlatConsoleText { - param([object] $Output) - $text = ($Output | Out-String) - $text = $text -replace '\r?\n\s*\|\s*', ' ' - return ($text -replace '\s+', ' ').Trim() + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $script:channelPublisher = Join-Path $script:repoRoot 'scripts/Publish-GraphKitPackage.ps1' + $script:galleryPublisher = Join-Path $script:repoRoot 'scripts/Publish-GraphKitToGallery.ps1' + $script:publishers = @($script:channelPublisher, $script:galleryPublisher) + + function Get-ScriptCommandMetadata { + param([Parameter(Mandatory)] [string] $Path) + return Get-Command -Name $Path -CommandType ExternalScript } - # Publish-GraphKitPackage is the one place where "what we tested" and "what we ship" can - # silently diverge. Its refusals are the product; each is mutation-tested here by - # constructing exactly the situation it must reject. - $script:repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $script:publish = Join-Path $script:repoRoot 'scripts/Publish-GraphKitPackage.ps1' - - function New-FakeNupkg { - param([string] $Root, [string] $Name = 'GraphKit.9.9.9.nupkg', [string] $Psm1Content = 'fake module body') - $path = Join-Path $Root $Name - Add-Type -AssemblyName System.IO.Compression.FileSystem - $stage = Join-Path $Root ("stage-{0}" -f [guid]::NewGuid()) - $null = New-Item -ItemType Directory -Path $stage -Force - Set-Content -LiteralPath (Join-Path $stage 'GraphKit.psm1') -Value $Psm1Content -NoNewline - if (Test-Path $path) { Remove-Item $path -Force } - [System.IO.Compression.ZipFile]::CreateFromDirectory($stage, $path) - Remove-Item $stage -Recurse -Force - return $path - } - - function New-PassingResult { - param([string] $Root, [string] $Version = '9.9.9', [int] $Total = 777) - $path = Join-Path $Root "NUnitXml_GraphKit_v$Version.Test.xml" - @" - - - - -"@ | Set-Content -LiteralPath $path -Encoding utf8 - return $path - } - - function Invoke-Publish { - param([hashtable] $Params) - $args = @() - foreach ($k in $Params.Keys) { - if ($Params[$k] -is [switch] -or $Params[$k] -is [bool]) { - if ($Params[$k]) { $args += "-$k" } - } - else { $args += "-$k"; $args += [string] $Params[$k] } - } - $out = & pwsh -NoProfile -File $script:publish @args 2>&1 - return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = (ConvertTo-FlatConsoleText -Output $out) } + function Get-ScriptAst { + param([Parameter(Mandatory)] [string] $Path) + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref] $tokens, [ref] $errors) + @($errors).Count | Should -Be 0 + return $ast } } -Describe 'Publish-GraphKitPackage refusals' { - - It 'refuses a package that does not exist, and says not to build here' { - $r = Invoke-Publish @{ PackagePath = (Join-Path $TestDrive 'nope.nupkg'); Channel = 'FileSystem'; Destination = $TestDrive; SkipTestProof = $true } - $r.ExitCode | Should -Not -Be 0 - # Match a phrase short enough to survive console line-wrapping in the error text. - $r.Output | Should -BeLike '*does not exist*' - } - - It 'refuses a file that is not a .nupkg' { - $bogus = Join-Path $TestDrive 'GraphKit.9.9.9.zip' - 'x' | Set-Content -LiteralPath $bogus - $r = Invoke-Publish @{ PackagePath = $bogus; Channel = 'FileSystem'; Destination = $TestDrive; SkipTestProof = $true } - $r.ExitCode | Should -Not -Be 0 - $r.Output | Should -BeLike '*not a .nupkg*' - } - - It 'refuses a package whose name carries no parseable version' { - $bad = New-FakeNupkg -Root $TestDrive -Name 'GraphKit.nupkg' - $r = Invoke-Publish @{ PackagePath = $bad; Channel = 'FileSystem'; Destination = $TestDrive; SkipTestProof = $true } - $r.ExitCode | Should -Not -Be 0 - $r.Output | Should -BeLike '*Cannot parse a module name and version*' +Describe 'GraphKit publisher release-proof boundary' -Tag 'QA' { + It 'removes every test-proof bypass from the channel publisher' { + $command = Get-ScriptCommandMetadata -Path $script:channelPublisher + $command.Parameters.Keys | Should -Not -Contain 'SkipTestProof' + $command.Parameters.Keys | Should -Not -Contain 'TestResultPath' } - It 'refuses to publish without test proof unless the escape hatch is explicit' { - $pkg = New-FakeNupkg -Root $TestDrive - $r = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = (Join-Path $TestDrive 'ch1') } - $r.ExitCode | Should -Not -Be 0 - $r.Output | Should -BeLike '*-TestResultPath is required*' - } - - It 'refuses a test result belonging to a different version' { - # A green result from another build proves nothing about these bytes. - $pkg = New-FakeNupkg -Root $TestDrive - $wrong = New-PassingResult -Root $TestDrive -Version '1.2.3' - $r = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = (Join-Path $TestDrive 'ch2'); TestResultPath = $wrong } - $r.ExitCode | Should -Not -Be 0 - $r.Output | Should -Match 'does not reference version|not the one the tests ran against|built module at' + It 'requires a canonical proof for both publishers' { + foreach ($path in $script:publishers) { + $command = Get-ScriptCommandMetadata -Path $path + $command.Parameters.Keys | Should -Contain 'ProofPath' + $command.Parameters['ProofPath'].Attributes.Mandatory | Should -Contain $true + } } - It 'refuses when the tested build is gone, so provenance cannot be established' { - # output/module/GraphKit/9.9.9 does not exist, so nothing ties this package to a run. - $pkg = New-FakeNupkg -Root $TestDrive - $result = New-PassingResult -Root $TestDrive -Version '9.9.9' - $r = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = (Join-Path $TestDrive 'ch3'); TestResultPath = $result } - $r.ExitCode | Should -Not -Be 0 - $r.Output | Should -BeLike '*cannot be tied back to the tested bits*' - } + It 'makes both publishers report-only unless the named Publish switch is present' { + foreach ($path in $script:publishers) { + $command = Get-ScriptCommandMetadata -Path $path + $command.Parameters.Keys | Should -Contain 'Publish' + $command.Parameters['Publish'].ParameterType | Should -Be ([switch]) - It 'refuses a gate-failing test result' { - $pkg = New-FakeNupkg -Root $TestDrive - $failing = Join-Path $TestDrive 'NUnitXml_GraphKit_v9.9.9.Fail.xml' - @' - - - - -'@ | Set-Content -LiteralPath $failing -Encoding utf8 - $r = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = (Join-Path $TestDrive 'ch4'); TestResultPath = $failing } - $r.ExitCode | Should -Not -Be 0 - $r.Output | Should -BeLike '*did not pass the whole-result gate*' + $ast = Get-ScriptAst -Path $path + $source = $ast.Extent.Text + $source | Should -Match '(?s)Test-GraphKitReleaseProof\.ps1.*if\s*\(\s*-not\s+\$Publish\s*\)' + $source | Should -Match 'report-only|Nothing was published' + } } - Context 'channel immutability' { - - It 'refuses to replace an existing version with different bytes' { - # Replacing a version under an existing pin makes every pin naming it a lie. - $channel = Join-Path $TestDrive 'immutable' - $null = New-Item -ItemType Directory -Path $channel -Force - - $first = New-FakeNupkg -Root $TestDrive -Psm1Content 'body one' - $r1 = Invoke-Publish @{ PackagePath = $first; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; PinPath = (Join-Path $TestDrive 'p1.json') } - $r1.ExitCode | Should -Be 0 - - $second = New-FakeNupkg -Root (Join-Path $TestDrive 'v2') -Psm1Content 'body two DIFFERENT' - $r2 = Invoke-Publish @{ PackagePath = $second; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; PinPath = (Join-Path $TestDrive 'p2.json') } - $r2.ExitCode | Should -Not -Be 0 - $r2.Output | Should -BeLike '*DIFFERENT bytes*' + It 'requires normal ShouldProcess authorization on both outward paths' { + foreach ($path in $script:publishers) { + $command = Get-ScriptCommandMetadata -Path $path + $command.Parameters.Keys | Should -Contain 'WhatIf' + (Get-Content -LiteralPath $path -Raw) | Should -Match '\$PSCmdlet\.ShouldProcess\(' } + } - It 'accepts a republish of byte-identical content as a no-op' { - $channel = Join-Path $TestDrive 'idempotent' - $null = New-Item -ItemType Directory -Path $channel -Force - $pkg = New-FakeNupkg -Root (Join-Path $TestDrive 'same') -Psm1Content 'identical body' - - $r1 = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; PinPath = (Join-Path $TestDrive 'q1.json') } - $r2 = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; PinPath = (Join-Path $TestDrive 'q2.json') } - $r1.ExitCode | Should -Be 0 - $r2.ExitCode | Should -Be 0 - $r2.Output | Should -BeLike '*Already published with identical bytes*' + It 'uses the verifier-owned package snapshot for every publish command' { + foreach ($path in $script:publishers) { + $source = Get-Content -LiteralPath $path -Raw + $source | Should -Match 'PackageSnapshotPath' + $source | Should -Match '\$verified\.PackageSnapshotPath' } } - It 'writes a pin record naming the exact bytes' { - $channel = Join-Path $TestDrive 'pinned' - $pkg = New-FakeNupkg -Root (Join-Path $TestDrive 'pinsrc') - $pinPath = Join-Path $TestDrive 'pin.json' - $r = Invoke-Publish @{ PackagePath = $pkg; Channel = 'FileSystem'; Destination = $channel; SkipTestProof = $true; PinPath = $pinPath } - $r.ExitCode | Should -Be 0 - - $pin = Get-Content -LiteralPath $pinPath -Raw | ConvertFrom-Json - $pin.version | Should -Be '9.9.9' - $pin.sha256 | Should -Be (Get-FileHash -LiteralPath $pkg -Algorithm SHA256).Hash - $pin.testProof | Should -BeLike '*without test proof*' + It 'keeps the PSGallery API key out of the command line and reads it securely' { + $command = Get-ScriptCommandMetadata -Path $script:galleryPublisher + $command.Parameters.Keys | Should -Not -Contain 'ApiKey' + $source = Get-Content -LiteralPath $script:galleryPublisher -Raw + $source | Should -Match "Read-Host\s+'[^']*API key'\s+-AsSecureString" } } diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 new file mode 100644 index 0000000..6583b08 --- /dev/null +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -0,0 +1,211 @@ +BeforeAll { + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + $script:createProof = Join-Path $script:repoRoot 'scripts/New-GraphKitTestedReleaseProof.ps1' + $script:verifyProof = Join-Path $script:repoRoot 'scripts/Test-GraphKitReleaseProof.ps1' + + function Set-ZipEntryText { + param( + [Parameter(Mandatory)] [string] $PackagePath, + [Parameter(Mandatory)] [string] $EntryPath, + [Parameter(Mandatory)] [string] $Text, + [switch] $Add + ) + + $archive = [System.IO.Compression.ZipFile]::Open($PackagePath, 'Update') + try { + if (-not $Add) { + $existing = $archive.GetEntry($EntryPath) + if ($null -eq $existing) { throw "Fixture entry '$EntryPath' was not found." } + $existing.Delete() + } + $entry = $archive.CreateEntry($EntryPath) + $writer = [System.IO.StreamWriter]::new($entry.Open()) + try { $writer.Write($Text) } finally { $writer.Dispose() } + } + finally { $archive.Dispose() } + } + + function Remove-ZipEntry { + param([string] $PackagePath, [string] $EntryPath) + $archive = [System.IO.Compression.ZipFile]::Open($PackagePath, 'Update') + try { + $entry = $archive.GetEntry($EntryPath) + if ($null -eq $entry) { throw "Fixture entry '$EntryPath' was not found." } + $entry.Delete() + } + finally { $archive.Dispose() } + } + + function New-ReleaseProofFixture { + param([Parameter(Mandatory)] [string] $Root) + + $moduleRoot = Join-Path $Root 'output/module/GraphKit/0.3.1' + $operations = Join-Path $moduleRoot 'Data/Operations' + $testRoot = Join-Path $Root 'output/testResults' + $null = New-Item -ItemType Directory -Path $operations -Force + $null = New-Item -ItemType Directory -Path $testRoot -Force + + @" +@{ + RootModule = 'GraphKit.psm1' + ModuleVersion = '0.3.1' + GUID = '212637ed-2571-4e88-8df2-888a0c163ccd' + Author = 'Fixture' + Description = 'Fixture' +} +"@ | Set-Content -LiteralPath (Join-Path $moduleRoot 'GraphKit.psd1') -Encoding utf8 + 'function Get-Fixture { "ok" }' | Set-Content -LiteralPath (Join-Path $moduleRoot 'GraphKit.psm1') -Encoding utf8 + '@{ Name = "AppleEnrollmentProfile.ListByToken" }' | + Set-Content -LiteralPath (Join-Path $operations 'AppleEnrollmentProfile.ListByToken.psd1') -Encoding utf8 + '@{ Name = "ManagedDevice.GetBeta" }' | + Set-Content -LiteralPath (Join-Path $operations 'ManagedDevice.GetBeta.psd1') -Encoding utf8 + + $resultPath = Join-Path $testRoot 'NUnitXml_GraphKit_v0.3.1.Fixture.xml' + @' + + + + +'@ | Set-Content -LiteralPath $resultPath -Encoding utf8 + + $packagePath = Join-Path $Root 'output/GraphKit.0.3.1.nupkg' + $archive = [System.IO.Compression.ZipFile]::Open($packagePath, 'Create') + try { + foreach ($file in Get-ChildItem -LiteralPath $moduleRoot -File -Recurse) { + $relative = [System.IO.Path]::GetRelativePath($moduleRoot, $file.FullName).Replace('\', '/') + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($archive, $file.FullName, $relative) | Out-Null + } + foreach ($metadataPath in @('_rels/.rels', 'GraphKit.nuspec', '[Content_Types].xml', 'package/services/metadata/core-properties/fixture.psmdcp')) { + $entry = $archive.CreateEntry($metadataPath) + $writer = [System.IO.StreamWriter]::new($entry.Open()) + try { $writer.Write("fixture:$metadataPath") } finally { $writer.Dispose() } + } + } + finally { $archive.Dispose() } + + "/output/*.nupkg`n/proof.json`n" | Set-Content -LiteralPath (Join-Path $Root '.gitignore') -Encoding utf8 + & git -C $Root init --quiet + & git -C $Root config user.email fixture@example.invalid + & git -C $Root config user.name Fixture + & git -C $Root add . + & git -C $Root commit --quiet -m fixture + if ($LASTEXITCODE -ne 0) { throw 'Could not commit the release-proof fixture.' } + + $proofPath = Join-Path $Root 'proof.json' + & $script:createProof -PackagePath $packagePath -TestResultPath $resultPath -OutputPath $proofPath -RepositoryRoot $Root -MinimumTests 789 | Out-Null + + return [pscustomobject]@{ + Root = $Root + ModuleRoot = $moduleRoot + PackagePath = $packagePath + ResultPath = $resultPath + ProofPath = $proofPath + } + } + + function Set-ProofSourceRevisionToHead { + param([Parameter(Mandatory)] $Fixture) + $proof = Get-Content -LiteralPath $Fixture.ProofPath -Raw | ConvertFrom-Json + $proof.sourceRevision = (& git -C $Fixture.Root rev-parse HEAD).Trim() + $proof | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $Fixture.ProofPath -Encoding utf8 + } +} + +Describe 'GraphKit tested-release proof' -Tag 'QA' { + BeforeEach { + $script:fixture = New-ReleaseProofFixture -Root (Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))) + } + + It 'verifies the exact package, built tree, test result, and source revision' { + $verified = & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath ` + -RepositoryRoot $fixture.Root -TestResultPath $fixture.ResultPath + + $verified.Version | Should -BeExactly '0.3.1' + $verified.TestCount | Should -BeGreaterOrEqual 789 + $verified.ShippedFileCount | Should -BeGreaterThan 2 + $verified.PackageSha256 | Should -Match '^[0-9a-f]{64}$' + $verified.SourceRevision | Should -Match '^[0-9a-f]{40}$' + } + + It 'rejects changed Apple descriptor bytes inside the package' { + Set-ZipEntryText -PackagePath $fixture.PackagePath -EntryPath 'Data/Operations/AppleEnrollmentProfile.ListByToken.psd1' -Text 'tampered' + { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | + Should -Throw -ExpectedMessage '*archive file digest mismatch*AppleEnrollmentProfile*' + } + + It 'rejects changed managed-device descriptor bytes in the built tree' { + 'tampered' | Set-Content -LiteralPath (Join-Path $fixture.ModuleRoot 'Data/Operations/ManagedDevice.GetBeta.psd1') + & git -C $fixture.Root add output/module/GraphKit/0.3.1/Data/Operations/ManagedDevice.GetBeta.psd1 + & git -C $fixture.Root commit --quiet -m mutation + Set-ProofSourceRevisionToHead -Fixture $fixture + { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | + Should -Throw -ExpectedMessage '*built file digest mismatch*ManagedDevice.GetBeta*' + } + + It 'rejects an unlisted package entry' { + Set-ZipEntryText -PackagePath $fixture.PackagePath -EntryPath 'unexpected.txt' -Text 'extra' -Add + { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | + Should -Throw -ExpectedMessage '*unlisted archive entry*unexpected.txt*' + } + + It 'rejects a missing listed package entry' { + Remove-ZipEntry -PackagePath $fixture.PackagePath -EntryPath 'GraphKit.psm1' + { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | + Should -Throw -ExpectedMessage '*archive file set mismatch*GraphKit.psm1*' + } + + It 'rejects unsafe archive traversal paths' { + Set-ZipEntryText -PackagePath $fixture.PackagePath -EntryPath '../escape' -Text 'unsafe' -Add + { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | + Should -Throw -ExpectedMessage '*unsafe archive path*' + } + + It 'rejects changed NUnit bytes' { + (Get-Content -LiteralPath $fixture.ResultPath -Raw).Replace('total="789"', 'total="790"') | + Set-Content -LiteralPath $fixture.ResultPath -Encoding utf8 + & git -C $fixture.Root add output/testResults/NUnitXml_GraphKit_v0.3.1.Fixture.xml + & git -C $fixture.Root commit --quiet -m result-mutation + Set-ProofSourceRevisionToHead -Fixture $fixture + { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root -TestResultPath $fixture.ResultPath } | + Should -Throw -ExpectedMessage '*test result digest mismatch*' + } + + It 'rejects a stale source revision' { + $proof = Get-Content -LiteralPath $fixture.ProofPath -Raw | ConvertFrom-Json + $proof.sourceRevision = '0' * 40 + $proof | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $fixture.ProofPath -Encoding utf8 + { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | + Should -Throw -ExpectedMessage '*source revision mismatch*' + } + + It 'rejects case-insensitive duplicate archive paths' { + Set-ZipEntryText -PackagePath $fixture.PackagePath -EntryPath 'graphkit.psm1' -Text 'duplicate' -Add + { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | + Should -Throw -ExpectedMessage '*duplicate archive path*' + } + + It 'rejects a proof naming another package' { + $proof = Get-Content -LiteralPath $fixture.ProofPath -Raw | ConvertFrom-Json + $proof.package.name = 'GraphKit.0.3.1-other.nupkg' + $proof | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $fixture.ProofPath -Encoding utf8 + { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | + Should -Throw -ExpectedMessage '*package name mismatch*' + } + + It 'rejects a symlink supplied in place of the package' { + $linkPath = Join-Path $fixture.Root 'linked-package.nupkg' + $null = New-Item -ItemType SymbolicLink -Path $linkPath -Target $fixture.PackagePath + { & $script:verifyProof -PackagePath $linkPath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | + Should -Throw -ExpectedMessage '*must be a regular file*symlink*' + } + + It 'rejects unknown top-level proof members' { + $proof = Get-Content -LiteralPath $fixture.ProofPath -Raw | ConvertFrom-Json + $proof | Add-Member -NotePropertyName surprise -NotePropertyValue $true + $proof | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $fixture.ProofPath -Encoding utf8 + { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | + Should -Throw -ExpectedMessage '*Proof members mismatch*surprise*' + } +} From d2341003a2136ef420e7b2a9a9896588ea96328e Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 6 Sep 2026 17:37:35 -0400 Subject: [PATCH 6/7] fix: keep internal names out of package metadata --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 2 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 2 +- scripts/Test-GraphKitReleaseProof.ps1 | 2 +- tests/QA/PackageIdentity.tests.ps1 | 14 ++++++++++++++ tests/QA/ReleaseProof.tests.ps1 | 8 ++++---- 7 files changed, 23 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8049ac5..97d53fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,4 +93,4 @@ jobs: if ($resultFiles.Count -gt 1) { throw "Multiple NUnit result files produced: $($resultFiles.Name -join ', ')" } - pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 789 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 790 -AllowedSkips 0 diff --git a/AGENTS.md b/AGENTS.md index baa9554..dcf6cf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Two things about the container are worth knowing before repeating it. The immuta Run the suite through `./build.ps1 -Tasks test`, never `Invoke-Pester ./tests` directly: the changelog checks are Sampler-generated and depend on build-injected variables, so a bare Pester run reports two false failures. -**Remote CI contract.** `.github/workflows/ci.yml` runs PowerShell 7.4 and 7.6 across Windows, Ubuntu, and macOS. A source revision is CI-verified only when all six matrix jobs pass for that exact SHA; workflow existence or an older successful run is not evidence. The published `0.3.0` evidence is 772 deterministic tests. The `0.3.1` maintenance tree requires 789 deterministic tests under `./build.ps1 -Tasks test`, with zero failures, errors, or skips, and its all-file tested-release proof enforces the same minimum-count floor used by CI and package verification. +**Remote CI contract.** `.github/workflows/ci.yml` runs PowerShell 7.4 and 7.6 across Windows, Ubuntu, and macOS. A source revision is CI-verified only when all six matrix jobs pass for that exact SHA; workflow existence or an older successful run is not evidence. The published `0.3.0` evidence is 772 deterministic tests. The `0.3.1` maintenance tree requires 790 deterministic tests under `./build.ps1 -Tasks test`, with zero failures, errors, or skips, and its all-file tested-release proof enforces the same minimum-count floor used by CI and package verification. **Phase 5 (cutover) implementation and Ivy24 verification are complete.** All eight steps ran and were verified against the Ivy24 lab tenant: legacy-caller inventory, `Import-GraphLegacyProfile`, a private versioned package channel with publish/pin/install, a live read through the *installed* package, a GraphKit-backed data plane in IHA behind a default-off flag, reads and a reverted mutating write through it, and a full credential-generation rollover ending in the old generation's revocation. Catalog coverage of IHA's declared surface is 27 of 27 at the API version it actually calls. The 2026-08-15 cutover record preserved two operator actions because active customer repointing would have required the legacy fallback to remain. Current operator status on 2026-08-29 is that no legacy or customer-tenant consumer uses these paths, so that historical contingency is not a `0.3.0` release blocker; the repository does not independently inventory external consumers. Purging deleted directory data remains policy-controlled housekeeping rather than package work. Read `docs/cutover/2026-08-15-phase5-cutover.md` before revisiting the historical cutover. diff --git a/CHANGELOG.md b/CHANGELOG.md index 151adca..9e99a43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] GraphKit `0.3.1` is an unpublished maintenance bridge candidate for TenantPulse's -IntuneHealthAutomation successor path. It has not been published to PSGallery and does not replace +successor data path. It has not been published to PSGallery and does not replace the immutable `0.3.0` release evidence below. ### Added diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index 0b7b06c..b82aec9 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -14,7 +14,7 @@ param( [Parameter(Mandatory)] [string] $TestResultPath, [Parameter(Mandatory)] [string] $OutputPath, [string] $RepositoryRoot = (Split-Path $PSScriptRoot -Parent), - [ValidateRange(1, [int]::MaxValue)] [int] $MinimumTests = 789 + [ValidateRange(1, [int]::MaxValue)] [int] $MinimumTests = 790 ) $ErrorActionPreference = 'Stop' diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 577a8a0..3db003b 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -174,7 +174,7 @@ foreach ($field in $actualCounts.Keys) { foreach ($zeroField in @('failures', 'errors', 'skipped', 'inconclusive', 'notRun')) { if ($actualCounts[$zeroField] -ne 0) { throw "Test result is not releasable: $zeroField=$($actualCounts[$zeroField])." } } -if ($actualCounts.total -lt 789) { throw "Test result has $($actualCounts.total) tests; release-proof minimum is 789." } +if ($actualCounts.total -lt 790) { throw "Test result has $($actualCounts.total) tests; release-proof minimum is 790." } $packageSnapshot = Copy-VerifiedFile -Source $package.FullName -Destination $PackageSnapshotPath -ExpectedHash $packageHash -Label 'Package' $proofHash = Get-LowerFileHash -Path $proofFile.FullName diff --git a/tests/QA/PackageIdentity.tests.ps1 b/tests/QA/PackageIdentity.tests.ps1 index 73a7af6..716d386 100644 --- a/tests/QA/PackageIdentity.tests.ps1 +++ b/tests/QA/PackageIdentity.tests.ps1 @@ -62,4 +62,18 @@ Describe 'GraphKit release package identity' -Tag 'QA' { $packagedManifest = Import-PowerShellDataFile (Join-Path $extractRoot 'GraphKit.psd1') [string] $packagedManifest.ModuleVersion | Should -Be $script:expectedVersion } + + It 'keeps internal lab and predecessor project names out of shipped package metadata' { + $archive = [System.IO.Compression.ZipFile]::OpenRead($script:packagePath) + try { + foreach ($entryName in @('GraphKit.psd1', 'GraphKit.nuspec')) { + $entry = $archive.GetEntry($entryName) + $entry | Should -Not -BeNullOrEmpty + $reader = [System.IO.StreamReader]::new($entry.Open()) + try { $content = $reader.ReadToEnd() } finally { $reader.Dispose() } + $content | Should -Not -Match '(?i)\bivy24\b|\bIntuneHealthAutomation\b' + } + } + finally { $archive.Dispose() } + } } diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 6583b08..9fd6865 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -65,7 +65,7 @@ BeforeAll { $resultPath = Join-Path $testRoot 'NUnitXml_GraphKit_v0.3.1.Fixture.xml' @' - + '@ | Set-Content -LiteralPath $resultPath -Encoding utf8 @@ -94,7 +94,7 @@ BeforeAll { if ($LASTEXITCODE -ne 0) { throw 'Could not commit the release-proof fixture.' } $proofPath = Join-Path $Root 'proof.json' - & $script:createProof -PackagePath $packagePath -TestResultPath $resultPath -OutputPath $proofPath -RepositoryRoot $Root -MinimumTests 789 | Out-Null + & $script:createProof -PackagePath $packagePath -TestResultPath $resultPath -OutputPath $proofPath -RepositoryRoot $Root -MinimumTests 790 | Out-Null return [pscustomobject]@{ Root = $Root @@ -123,7 +123,7 @@ Describe 'GraphKit tested-release proof' -Tag 'QA' { -RepositoryRoot $fixture.Root -TestResultPath $fixture.ResultPath $verified.Version | Should -BeExactly '0.3.1' - $verified.TestCount | Should -BeGreaterOrEqual 789 + $verified.TestCount | Should -BeGreaterOrEqual 790 $verified.ShippedFileCount | Should -BeGreaterThan 2 $verified.PackageSha256 | Should -Match '^[0-9a-f]{64}$' $verified.SourceRevision | Should -Match '^[0-9a-f]{40}$' @@ -163,7 +163,7 @@ Describe 'GraphKit tested-release proof' -Tag 'QA' { } It 'rejects changed NUnit bytes' { - (Get-Content -LiteralPath $fixture.ResultPath -Raw).Replace('total="789"', 'total="790"') | + (Get-Content -LiteralPath $fixture.ResultPath -Raw).Replace('total="790"', 'total="791"') | Set-Content -LiteralPath $fixture.ResultPath -Encoding utf8 & git -C $fixture.Root add output/testResults/NUnitXml_GraphKit_v0.3.1.Fixture.xml & git -C $fixture.Root commit --quiet -m result-mutation From 24a116b0c6a42bbcf8f4fa6e47e406dd885944ca Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 6 Sep 2026 17:43:32 -0400 Subject: [PATCH 7/7] fix: gate release proof on failed containers --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- scripts/New-GraphKitTestedReleaseProof.ps1 | 6 +++- scripts/Test-GraphKitReleaseProof.ps1 | 11 ++++++- tests/QA/ReleaseProof.tests.ps1 | 34 +++++++++++++++++++--- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97d53fb..ca90a2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,4 +93,4 @@ jobs: if ($resultFiles.Count -gt 1) { throw "Multiple NUnit result files produced: $($resultFiles.Name -join ', ')" } - pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 790 -AllowedSkips 0 + pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 791 -AllowedSkips 0 diff --git a/AGENTS.md b/AGENTS.md index dcf6cf8..3aba57b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Two things about the container are worth knowing before repeating it. The immuta Run the suite through `./build.ps1 -Tasks test`, never `Invoke-Pester ./tests` directly: the changelog checks are Sampler-generated and depend on build-injected variables, so a bare Pester run reports two false failures. -**Remote CI contract.** `.github/workflows/ci.yml` runs PowerShell 7.4 and 7.6 across Windows, Ubuntu, and macOS. A source revision is CI-verified only when all six matrix jobs pass for that exact SHA; workflow existence or an older successful run is not evidence. The published `0.3.0` evidence is 772 deterministic tests. The `0.3.1` maintenance tree requires 790 deterministic tests under `./build.ps1 -Tasks test`, with zero failures, errors, or skips, and its all-file tested-release proof enforces the same minimum-count floor used by CI and package verification. +**Remote CI contract.** `.github/workflows/ci.yml` runs PowerShell 7.4 and 7.6 across Windows, Ubuntu, and macOS. A source revision is CI-verified only when all six matrix jobs pass for that exact SHA; workflow existence or an older successful run is not evidence. The published `0.3.0` evidence is 772 deterministic tests. The `0.3.1` maintenance tree requires 791 deterministic tests under `./build.ps1 -Tasks test`, with zero failures, errors, or skips, and its all-file tested-release proof enforces the same minimum-count floor used by CI and package verification. **Phase 5 (cutover) implementation and Ivy24 verification are complete.** All eight steps ran and were verified against the Ivy24 lab tenant: legacy-caller inventory, `Import-GraphLegacyProfile`, a private versioned package channel with publish/pin/install, a live read through the *installed* package, a GraphKit-backed data plane in IHA behind a default-off flag, reads and a reverted mutating write through it, and a full credential-generation rollover ending in the old generation's revocation. Catalog coverage of IHA's declared surface is 27 of 27 at the API version it actually calls. The 2026-08-15 cutover record preserved two operator actions because active customer repointing would have required the legacy fallback to remain. Current operator status on 2026-08-29 is that no legacy or customer-tenant consumer uses these paths, so that historical contingency is not a `0.3.0` release blocker; the repository does not independently inventory external consumers. Purging deleted directory data remains policy-controlled housekeeping rather than package work. Read `docs/cutover/2026-08-15-phase5-cutover.md` before revisiting the historical cutover. diff --git a/scripts/New-GraphKitTestedReleaseProof.ps1 b/scripts/New-GraphKitTestedReleaseProof.ps1 index b82aec9..5c1bc0c 100644 --- a/scripts/New-GraphKitTestedReleaseProof.ps1 +++ b/scripts/New-GraphKitTestedReleaseProof.ps1 @@ -14,7 +14,7 @@ param( [Parameter(Mandatory)] [string] $TestResultPath, [Parameter(Mandatory)] [string] $OutputPath, [string] $RepositoryRoot = (Split-Path $PSScriptRoot -Parent), - [ValidateRange(1, [int]::MaxValue)] [int] $MinimumTests = 790 + [ValidateRange(1, [int]::MaxValue)] [int] $MinimumTests = 791 ) $ErrorActionPreference = 'Stop' @@ -86,6 +86,10 @@ if ([string] $manifest.ModuleVersion -ne $version) { [xml] $resultDocument = Get-Content -LiteralPath $testResult.FullName -Raw $resultRoot = $resultDocument.SelectSingleNode('/test-results') if ($null -eq $resultRoot) { throw "Test result '$($testResult.FullName)' has no /test-results root." } +$wholeResultGate = Join-Path $repo 'tests/QA/Assert-GateResult.ps1' +if (-not (Test-Path -LiteralPath $wholeResultGate -PathType Leaf)) { throw "Whole-result gate '$wholeResultGate' is absent." } +& pwsh -NoProfile -File $wholeResultGate -ResultPath $testResult.FullName -MinimumTests $MinimumTests -AllowedSkips 0 | Write-Verbose +if ($LASTEXITCODE -ne 0) { throw "Test result did not pass the whole-result gate at '$wholeResultGate'." } $counts = [ordered]@{ total = [int] $resultRoot.GetAttribute('total') failures = [int] $resultRoot.GetAttribute('failures') diff --git a/scripts/Test-GraphKitReleaseProof.ps1 b/scripts/Test-GraphKitReleaseProof.ps1 index 3db003b..57a09f1 100644 --- a/scripts/Test-GraphKitReleaseProof.ps1 +++ b/scripts/Test-GraphKitReleaseProof.ps1 @@ -163,6 +163,15 @@ if ((Get-LowerFileHash -Path $resultFile.FullName) -cne [string] $proof.testRun. [xml] $resultDocument = Get-Content -LiteralPath $resultFile.FullName -Raw $resultRoot = $resultDocument.SelectSingleNode('/test-results') if ($null -eq $resultRoot) { throw 'Test result has no /test-results root.' } +$topSuite = $resultRoot.SelectSingleNode('test-suite') +if ($null -eq $topSuite -or [string] $topSuite.GetAttribute('result') -notin @('Success', 'Passed')) { + throw 'Test result overall status is not successful.' +} +$failedSuites = @($resultRoot.SelectNodes('//test-suite[@result="Failure" or @result="Error"]')) +if ($failedSuites.Count -gt 0) { + $failedNames = @($failedSuites | ForEach-Object { [string] $_.GetAttribute('name') }) + throw "$($failedSuites.Count) failed container(s) or discovery error(s): $($failedNames -join ', ')." +} $actualCounts = @{ total = [int] $resultRoot.GetAttribute('total'); failures = [int] $resultRoot.GetAttribute('failures') errors = [int] $resultRoot.GetAttribute('errors'); skipped = [int] $resultRoot.GetAttribute('skipped') @@ -174,7 +183,7 @@ foreach ($field in $actualCounts.Keys) { foreach ($zeroField in @('failures', 'errors', 'skipped', 'inconclusive', 'notRun')) { if ($actualCounts[$zeroField] -ne 0) { throw "Test result is not releasable: $zeroField=$($actualCounts[$zeroField])." } } -if ($actualCounts.total -lt 790) { throw "Test result has $($actualCounts.total) tests; release-proof minimum is 790." } +if ($actualCounts.total -lt 791) { throw "Test result has $($actualCounts.total) tests; release-proof minimum is 791." } $packageSnapshot = Copy-VerifiedFile -Source $package.FullName -Destination $PackageSnapshotPath -ExpectedHash $packageHash -Label 'Package' $proofHash = Get-LowerFileHash -Path $proofFile.FullName diff --git a/tests/QA/ReleaseProof.tests.ps1 b/tests/QA/ReleaseProof.tests.ps1 index 9fd6865..f7eeeee 100644 --- a/tests/QA/ReleaseProof.tests.ps1 +++ b/tests/QA/ReleaseProof.tests.ps1 @@ -65,7 +65,7 @@ BeforeAll { $resultPath = Join-Path $testRoot 'NUnitXml_GraphKit_v0.3.1.Fixture.xml' @' - + '@ | Set-Content -LiteralPath $resultPath -Encoding utf8 @@ -85,6 +85,10 @@ BeforeAll { } finally { $archive.Dispose() } + $fixtureGateRoot = Join-Path $Root 'tests/QA' + $null = New-Item -ItemType Directory -Path $fixtureGateRoot -Force + Copy-Item -LiteralPath (Join-Path $script:repoRoot 'tests/QA/Assert-GateResult.ps1') ` + -Destination (Join-Path $fixtureGateRoot 'Assert-GateResult.ps1') "/output/*.nupkg`n/proof.json`n" | Set-Content -LiteralPath (Join-Path $Root '.gitignore') -Encoding utf8 & git -C $Root init --quiet & git -C $Root config user.email fixture@example.invalid @@ -94,7 +98,7 @@ BeforeAll { if ($LASTEXITCODE -ne 0) { throw 'Could not commit the release-proof fixture.' } $proofPath = Join-Path $Root 'proof.json' - & $script:createProof -PackagePath $packagePath -TestResultPath $resultPath -OutputPath $proofPath -RepositoryRoot $Root -MinimumTests 790 | Out-Null + & $script:createProof -PackagePath $packagePath -TestResultPath $resultPath -OutputPath $proofPath -RepositoryRoot $Root -MinimumTests 791 | Out-Null return [pscustomobject]@{ Root = $Root @@ -123,7 +127,7 @@ Describe 'GraphKit tested-release proof' -Tag 'QA' { -RepositoryRoot $fixture.Root -TestResultPath $fixture.ResultPath $verified.Version | Should -BeExactly '0.3.1' - $verified.TestCount | Should -BeGreaterOrEqual 790 + $verified.TestCount | Should -BeGreaterOrEqual 791 $verified.ShippedFileCount | Should -BeGreaterThan 2 $verified.PackageSha256 | Should -Match '^[0-9a-f]{64}$' $verified.SourceRevision | Should -Match '^[0-9a-f]{40}$' @@ -163,7 +167,7 @@ Describe 'GraphKit tested-release proof' -Tag 'QA' { } It 'rejects changed NUnit bytes' { - (Get-Content -LiteralPath $fixture.ResultPath -Raw).Replace('total="790"', 'total="791"') | + (Get-Content -LiteralPath $fixture.ResultPath -Raw).Replace('total="791"', 'total="792"') | Set-Content -LiteralPath $fixture.ResultPath -Encoding utf8 & git -C $fixture.Root add output/testResults/NUnitXml_GraphKit_v0.3.1.Fixture.xml & git -C $fixture.Root commit --quiet -m result-mutation @@ -208,4 +212,26 @@ Describe 'GraphKit tested-release proof' -Tag 'QA' { { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | Should -Throw -ExpectedMessage '*Proof members mismatch*surprise*' } + + It 'rejects a failed nested test container even when root failure counts claim zero' { + $content = Get-Content -LiteralPath $fixture.ResultPath -Raw + $content = $content.Replace( + '', + '' + ) + $content | Set-Content -LiteralPath $fixture.ResultPath -Encoding utf8 + & git -C $fixture.Root add output/testResults/NUnitXml_GraphKit_v0.3.1.Fixture.xml + & git -C $fixture.Root commit --quiet -m nested-failure + + { & $script:createProof -PackagePath $fixture.PackagePath -TestResultPath $fixture.ResultPath ` + -OutputPath (Join-Path $fixture.Root 'failed-proof.json') -RepositoryRoot $fixture.Root -MinimumTests 791 } | + Should -Throw -ExpectedMessage '*whole-result gate*' + + $proof = Get-Content -LiteralPath $fixture.ProofPath -Raw | ConvertFrom-Json + $proof.sourceRevision = (& git -C $fixture.Root rev-parse HEAD).Trim() + $proof.testRun.nunit.sha256 = (Get-FileHash -LiteralPath $fixture.ResultPath -Algorithm SHA256).Hash.ToLowerInvariant() + $proof | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $fixture.ProofPath -Encoding utf8 + { & $script:verifyProof -PackagePath $fixture.PackagePath -ProofPath $fixture.ProofPath -RepositoryRoot $fixture.Root } | + Should -Throw -ExpectedMessage '*failed container*Discovery*' + } }