From 18b74758bc4fa4345bd40e7cba1df4d8a92e21f5 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Thu, 24 Sep 2026 18:28:27 +0200 Subject: [PATCH] (bugfix): reset DataTable page scroll, key external login buttons, and sync RepoUtils --- CHANGELOG.md | 15 + README.md | 10 +- assets/docs/NPM_PUBLISH.md | 4 +- .../components/DataTable/DataTable.tsx | 67 +- .../components/DataTable/dataTableScroll.ts | 49 ++ src/components/oauth/ExternalLoginButtons.tsx | 20 +- src/package-lock.json | 142 +--- src/package.json | 8 +- .../DataTable/dataTableScroll.test.ts | 59 ++ .../engines/release/Invoke-ReleasePackage.ps1 | 4 +- utils/engines/release/scriptSettings.json | 4 +- utils/engines/test/Invoke-TestEngine.ps1 | 3 +- utils/modules/ChangelogSupport.psm1 | 57 +- utils/modules/Engine/EngineContext.psm1 | 32 + utils/modules/Engine/Import-EngineModules.ps1 | 1 + utils/modules/Engine/PluginSupport.psm1 | 626 +++++++++++++++--- utils/modules/Engine/ReleaseSupport.psm1 | 1 + utils/modules/Engine/TestSupport.psm1 | 1 + utils/modules/Engine/VaultSupport.psm1 | 373 +++++++++++ utils/modules/ExternalCommandSupport.psm1 | 2 +- utils/modules/TestRunner.psm1 | 30 +- .../DiscoverDotNetPackageArtifacts.psm1 | 69 -- .../plugins/DotNet/DotNetArtifactSupport.psm1 | 63 -- .../DotNet/DotNetCleanupArtifacts.psm1 | 122 ---- utils/plugins/DotNet/DotNetCreateArchive.psm1 | 102 --- utils/plugins/DotNet/DotNetNuGet.psm1 | 90 --- utils/plugins/DotNet/DotNetPack.psm1 | 98 --- utils/plugins/DotNet/DotNetPublish.psm1 | 75 --- .../plugins/DotNet/DotNetReleaseVersion.psm1 | 96 --- utils/plugins/DotNet/DotNetTest.psm1 | 90 --- utils/plugins/Npm/NpmPublish.psm1 | 53 +- utils/plugins/Npm/NpmReleaseVersion.psm1 | 6 +- utils/plugins/Platform/CollectCoverage.psm1 | 78 +++ .../Platform/ContainerEngineProbe.psm1 | 52 ++ .../Platform/DiscoverPackageArtifacts.psm1 | 124 ++++ .../plugins/Platform/FileReleaseVersion.psm1 | 13 +- utils/plugins/Platform/GitHub.psm1 | 23 +- .../plugins/Platform/ReleasePublishGuard.psm1 | 11 +- utils/templates/Dockerfile.ci.dotnet | 21 - utils/templates/Dockerfile.ci.npm | 22 - utils/templates/build/ci-dotnet-pack.sh | 11 - utils/templates/build/ci-npm-pack.sh | 12 - 42 files changed, 1584 insertions(+), 1155 deletions(-) create mode 100644 src/components/components/DataTable/dataTableScroll.ts create mode 100644 src/test/components/DataTable/dataTableScroll.test.ts create mode 100644 utils/modules/Engine/VaultSupport.psm1 delete mode 100644 utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 delete mode 100644 utils/plugins/DotNet/DotNetArtifactSupport.psm1 delete mode 100644 utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 delete mode 100644 utils/plugins/DotNet/DotNetCreateArchive.psm1 delete mode 100644 utils/plugins/DotNet/DotNetNuGet.psm1 delete mode 100644 utils/plugins/DotNet/DotNetPack.psm1 delete mode 100644 utils/plugins/DotNet/DotNetPublish.psm1 delete mode 100644 utils/plugins/DotNet/DotNetReleaseVersion.psm1 delete mode 100644 utils/plugins/DotNet/DotNetTest.psm1 create mode 100644 utils/plugins/Platform/CollectCoverage.psm1 create mode 100644 utils/plugins/Platform/ContainerEngineProbe.psm1 create mode 100644 utils/plugins/Platform/DiscoverPackageArtifacts.psm1 delete mode 100644 utils/templates/Dockerfile.ci.dotnet delete mode 100644 utils/templates/Dockerfile.ci.npm delete mode 100644 utils/templates/build/ci-dotnet-pack.sh delete mode 100644 utils/templates/build/ci-npm-pack.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 4154ceb..29afd14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.6] - 2026-09-24 + +### Fixed + +- `DataTable`: after a page change, the grid scroll position resets (top for next/buttons, bottom when scrolling into the previous page) so the scrollbar no longer stays parked on the old edge. +- `ExternalLoginButtons`: each provider button is keyed, so React no longer warns about a missing key when several providers render. + +### Changed + +- RepoUtils: SemVer prerelease (`X.Y.Z-alpha.1` / `beta` / `rc`) in changelog parsing, version files, publish-guard tags (`v0.1.0-alpha.1`), GitHub `--prerelease`, and npm dist-tags (prerelease does not move `latest`). +- RepoUtils: optional Vault secret loading. Release settings declare `RepoUtilsSecretsShared` / `RepoUtilsSecrets`; GitHub uses the `GitClone` slot and npm uses `Npm`. `NpmPublish` stages with `npm stage publish` (npm CLI 11.15+) so a maintainer approves the version with 2FA. Plugin success requires an exact `$true` (CLI stdout mixed into the return value no longer counts as success). +- RepoUtils: dropped unused DotNet pack/publish/test plugins and CI Docker/npm pack templates. Added `CollectCoverage`, `ContainerEngineProbe`, and `DiscoverPackageArtifacts`. +- README: release steps document prerelease tags and link to maksit-repoutils. +- `src/package-lock.json` refreshed for npm 12, and `allowScripts` permits the `esbuild`, `msw`, and `unrs-resolver` install scripts that `npm ci` otherwise blocks. + ## [0.4.5] - 2026-08-12 ### Changed diff --git a/README.md b/README.md index 53f8e2d..884e632 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ Tests and coverage badges (shields.io URLs in this README): **`utils\Invoke-Test ## Release to npmjs -1. Set **`Npm`** environment variable to your npm automation token (logical secret name in `scriptSettings.json`). -2. Bump **`src/package.json`** `version` (and tag `vX.Y.Z` on `main` when using the publish guard). +1. Set **`RepoUtilsSecretsShared`** and **`RepoUtilsSecrets`** to JSON objects. Slots used here: **`Npm`** (npm automation token) and **`GitClone`** (GitHub token). The repo pack overrides the shared pack. +2. Bump **`src/package.json`** `version` (and tag `v{version}` on `main` when using the publish guard — `v1.2.3` or SemVer prerelease such as `v0.1.0-alpha.1`). Prerelease versions publish with npm dist-tag `alpha`/`beta`/`rc` and do not move `latest`. 3. Run **`utils\Invoke-ReleasePackage.bat`** (or `pwsh utils\engines\release\Invoke-ReleasePackage.ps1`). Configured plugins (see `utils\engines\release\scriptSettings.json`): @@ -41,10 +41,10 @@ Configured plugins (see `utils\engines\release\scriptSettings.json`): | `NpmReleaseVersion` | Read semver from `src/package.json` | | `NpmBuild` | `npm ci` + `npm run build` | | `ReleasePublishGuard` | Branch/tag checks before publish | -| `GitHub` | GitHub release (optional; set `GitHub` env var) | -| `NpmPublish` | Publish `@maks-it.com/webui` | +| `GitHub` | GitHub release (optional; `GitClone` slot in the secrets pack) | +| `NpmPublish` | Stage `@maks-it.com/webui` (`npm stage publish`). A maintainer approves with 2FA (`npm stage approve`). Requires npm CLI 11.15 or newer. | -Refresh shared utils from **maksit-repoutils** via local-copy sync (no Update-RepoUtils in product repos). +Refresh shared utils from **[maksit-repoutils](https://git.maks-it.com/MAKS-IT/maksit-repoutils)** via local-copy sync (no Update-RepoUtils in product repos). ## Consume in product repos diff --git a/assets/docs/NPM_PUBLISH.md b/assets/docs/NPM_PUBLISH.md index b8eb286..25f9a60 100644 --- a/assets/docs/NPM_PUBLISH.md +++ b/assets/docs/NPM_PUBLISH.md @@ -17,7 +17,7 @@ Published package: 3. Create an **Automation** token (recommended) or Granular Access token with **Publish** on `@maks-it.com/*`: - https://www.npmjs.com/settings/maks-it.com/tokens 4. Store the token for release tooling: - - **CI / release engine:** set environment variable **`Npm`** (logical secret name in `scriptSettings.json`). + - **CI / release engine:** set **`RepoUtilsSecretsShared`** and **`RepoUtilsSecrets`** to JSON objects. The **`Npm`** slot is the automation token; **`GitClone`** is the GitHub token. The repo pack overrides the shared pack. - **Local one-off publish:** `npm login` or a user-level `~/.npmrc` entry: ``` //registry.npmjs.org/:_authToken=YOUR_TOKEN @@ -48,7 +48,7 @@ Use **`utils\Invoke-ReleasePackage-Single.bat`** (or `pwsh utils\engines\release 1. Bump version in `src/package.json` (or tag drives `NpmReleaseVersion`). 2. Tag `HEAD` with exact semver, e.g. `git tag v0.4.0 && git push origin v0.4.0`. -3. Set `$env:Npm` and run the release engine. +3. Set `$env:RepoUtilsSecretsShared` and `$env:RepoUtilsSecrets` (JSON packs with `Npm` and `GitClone`) and run the release engine. `NpmPublish` runs `npm stage publish` (npm CLI 11.15+). Approve the staged version with 2FA: `npm stage list @maks-it.com/webui`, then `npm stage approve `. `utils\engines\release\scriptSettings.json` runs `NpmReleaseVersion`, `NpmBuild`, `ReleasePublishGuard`, optional `GitHub`, then `NpmPublish`. diff --git a/src/components/components/DataTable/DataTable.tsx b/src/components/components/DataTable/DataTable.tsx index 50cac62..20b2e52 100644 --- a/src/components/components/DataTable/DataTable.tsx +++ b/src/components/components/DataTable/DataTable.tsx @@ -4,6 +4,7 @@ import { AutoSizer, MultiGrid, GridCellProps } from 'react-virtualized' import { mapPagedToDataTable, type DataTablePageView, type PagedResponse } from '@webui/core' import { Plus, Trash2, Edit } from 'lucide-react' import { debounce, colSpanClass, type GridColSpan } from '../../functions' +import { dataTableScrollEdge, forcedScrollTopFor, SCROLL_EDGE_PX, type DataTableScrollReset } from './dataTableScroll' interface FilterProps { @@ -90,6 +91,10 @@ const DataTable = ,>(props: DataTableProps) const gridRef = useRef(null) const filterMeasureRef = useRef(null) + const pendingReset = useRef(null) + const suppressEdge = useRef(false) + const scrollTopRef = useRef(0) + const [forcedScrollTop, setForcedScrollTop] = useState(undefined) const [selectedRowIndex, setSelectedRowIndex] = useState(null) const [measuredFilterRowHeight, setMeasuredFilterRowHeight] = useState(0) @@ -214,8 +219,35 @@ const DataTable = ,>(props: DataTableProps) debouncedOnFilterChange?.(linqQueries) } - const handlePreviousPage = () => onPreviousPage?.(pageNumber - 1) - const handleNextPage = () => onNextPage?.(pageNumber + 1) + const handlePreviousPage = () => { + if (pendingReset.current) + return + + pendingReset.current = 'top' + onPreviousPage?.(pageNumber - 1) + } + + const handleNextPage = () => { + if (pendingReset.current) + return + + pendingReset.current = 'top' + onNextPage?.(pageNumber + 1) + } + + useEffect(() => { + const target = pendingReset.current + if (!target) + return + + pendingReset.current = null + + if (target === 'top' && scrollTopRef.current <= SCROLL_EDGE_PX) + return + + suppressEdge.current = true + setForcedScrollTop(forcedScrollTopFor(target)) + }, [pageNumber, items]) const getRealIdsFromRow = (rowIndex: number) => { @@ -423,12 +455,32 @@ const DataTable = ,>(props: DataTableProps) clientHeight: number scrollHeight: number }) => { - if (scrollTop + clientHeight >= scrollHeight - 2 && hasNextPage) { - handleNextPage() - } - if (scrollTop <= 2 && hasPreviousPage) { - handlePreviousPage() + scrollTopRef.current = scrollTop + + if (suppressEdge.current) { + suppressEdge.current = false + setForcedScrollTop(undefined) + return } + + const edge = dataTableScrollEdge({ + scrollTop, + clientHeight, + scrollHeight, + hasNextPage, + hasPreviousPage, + pendingReset: pendingReset.current !== null, + }) + + if (edge.type === 'none') + return + + pendingReset.current = edge.reset + + if (edge.type === 'next') + onNextPage?.(pageNumber + 1) + else + onPreviousPage?.(pageNumber - 1) } return ( @@ -463,6 +515,7 @@ const DataTable = ,>(props: DataTableProps) rowCount={items.length + HEADER_ROWS} rowHeight={({ index }) => index === 1 ? measuredFilterRowHeight : ROW_HEIGHT} width={width} + {...(forcedScrollTop !== undefined ? { scrollTop: forcedScrollTop } : {})} onScroll={({ scrollTop, clientHeight, scrollHeight }) => handleGridScroll({ scrollTop, clientHeight, scrollHeight }) } diff --git a/src/components/components/DataTable/dataTableScroll.ts b/src/components/components/DataTable/dataTableScroll.ts new file mode 100644 index 0000000..4081348 --- /dev/null +++ b/src/components/components/DataTable/dataTableScroll.ts @@ -0,0 +1,49 @@ +export const SCROLL_EDGE_PX = 2 + +export type DataTableScrollReset = 'top' | 'bottom' + +export type DataTableScrollEdge = + | { type: 'none' } + | { type: 'next'; reset: 'top' } + | { type: 'previous'; reset: 'bottom' } + +export const dataTableScrollEdge = ({ + scrollTop, + clientHeight, + scrollHeight, + hasNextPage, + hasPreviousPage, + pendingReset, +}: { + scrollTop: number + clientHeight: number + scrollHeight: number + hasNextPage: boolean + hasPreviousPage: boolean + pendingReset: boolean +}): DataTableScrollEdge => { + if (pendingReset) + return { type: 'none' } + + const overflows = scrollHeight > clientHeight + SCROLL_EDGE_PX + if (!overflows) + return { type: 'none' } + + const atBottom = scrollTop + clientHeight >= scrollHeight - SCROLL_EDGE_PX + if (atBottom && hasNextPage) + return { type: 'next', reset: 'top' } + + const atTop = scrollTop <= SCROLL_EDGE_PX + if (atTop && hasPreviousPage) + return { type: 'previous', reset: 'bottom' } + + return { type: 'none' } +} + +/** Pixel `scrollTop` passed to MultiGrid. `undefined` leaves the grid uncontrolled. */ +export const forcedScrollTopFor = (reset: DataTableScrollReset): number => { + if (reset === 'top') + return 0 + + return Number.MAX_SAFE_INTEGER +} diff --git a/src/components/oauth/ExternalLoginButtons.tsx b/src/components/oauth/ExternalLoginButtons.tsx index 13740d7..19ff424 100644 --- a/src/components/oauth/ExternalLoginButtons.tsx +++ b/src/components/oauth/ExternalLoginButtons.tsx @@ -1,4 +1,4 @@ -import type { FC, ReactNode } from 'react' +import { Fragment, type FC, type ReactNode } from 'react' import { LoginProviderExternal } from '@webui/contracts' export interface ExternalLoginProviderConfig { @@ -45,14 +45,16 @@ const ExternalLoginButtons: FC = ({ renderButton = defaultRenderButton, }) => (
- {providers.map(({ provider, label, iconSrc }) => - renderButton({ - provider, - label, - iconSrc, - onClick: () => onSelect(provider), - }), - )} + {providers.map(({ provider, label, iconSrc }) => ( + + {renderButton({ + provider, + label, + iconSrc, + onClick: () => onSelect(provider), + })} + + ))}
) diff --git a/src/package-lock.json b/src/package-lock.json index a8acf1a..a64c572 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@maks-it.com/webui", - "version": "0.4.3", + "version": "0.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@maks-it.com/webui", - "version": "0.4.3", + "version": "0.4.6", "license": "MIT", "dependencies": { "date-fns": "^4.4.0" @@ -131,7 +131,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -705,7 +704,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -729,7 +727,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -757,6 +754,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -3566,7 +3574,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3863,7 +3870,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3874,7 +3880,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4294,7 +4299,6 @@ "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.10", @@ -4318,7 +4322,6 @@ "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.1.10", "@vitest/mocker": "4.1.10", @@ -4343,7 +4346,6 @@ "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", @@ -4493,7 +4495,6 @@ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" @@ -4915,7 +4916,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5714,7 +5714,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6622,7 +6621,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -7280,7 +7278,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8035,7 +8032,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", @@ -8674,7 +8670,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -8838,7 +8833,6 @@ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8881,7 +8875,6 @@ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9118,7 +9111,6 @@ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9372,7 +9364,6 @@ "integrity": "sha512-bmLxPsxVSPnbeiZqYQpozyNOiJXfk+pf7WfHZflvPkwT6Y+rvYz3Cj/D6H4Kf2jHpuDNiMXBKO3yawLN2OWirg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", @@ -10053,7 +10044,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10237,7 +10227,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10797,7 +10786,6 @@ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", @@ -11200,110 +11188,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "packages/components": { - "name": "@maks-it.com/webui-components", - "version": "0.3.4", - "extraneous": true, - "dependencies": { - "@maks-it.com/webui-contracts": "^0.3.4", - "@maks-it.com/webui-core": "^0.3.4" - }, - "devDependencies": { - "@tanstack/react-table": "^8.21.3", - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@types/react-virtualized": "^9.22.3", - "lucide-react": "^1.16.0", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "react-router-dom": "^7.15.1", - "react-virtualized": "^9.22.6", - "tsup": "^8.5.1", - "typescript": "^6.0.3", - "zod": "^4.4.3" - }, - "peerDependencies": { - "@tanstack/react-table": "^8.0.0", - "lucide-react": "^1.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "react-router-dom": "^7.0.0", - "react-virtualized": "^9.22.0", - "zod": "^4.4.0" - } - }, - "packages/contracts": { - "name": "@maks-it.com/webui-contracts", - "version": "0.3.4", - "extraneous": true, - "devDependencies": { - "tsup": "^8.5.1", - "typescript": "^6.0.3", - "zod": "^4.4.3" - }, - "peerDependencies": { - "zod": "^4.4.0" - } - }, - "packages/core": { - "name": "@maks-it.com/webui-core", - "version": "0.3.4", - "extraneous": true, - "dependencies": { - "@maks-it.com/webui-contracts": "^0.3.4", - "date-fns": "^4.3.0" - }, - "devDependencies": { - "@microsoft/signalr": "^8.0.7", - "@types/react": "^19.2.15", - "axios": "^1.16.1", - "react": "^19.2.6", - "tsup": "^8.5.1", - "typescript": "^6.0.3", - "zod": "^4.4.3" - }, - "peerDependencies": { - "@microsoft/signalr": "^8.0.0", - "axios": "^1.16.0", - "react": "^18.0.0 || ^19.0.0", - "zod": "^4.4.0" - } - }, - "packages/webui": { - "name": "@maks-it.com/webui", - "version": "0.4.0", - "extraneous": true, - "dependencies": { - "date-fns": "^4.3.0" - }, - "devDependencies": { - "@microsoft/signalr": "^8.0.7", - "@tanstack/react-table": "^8.21.3", - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@types/react-virtualized": "^9.22.3", - "axios": "^1.16.1", - "lucide-react": "^1.16.0", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "react-router-dom": "^7.15.1", - "react-virtualized": "^9.22.6", - "tsup": "^8.5.1", - "typescript": "^6.0.3", - "zod": "^4.4.3" - }, - "peerDependencies": { - "@microsoft/signalr": "^8.0.0", - "@tanstack/react-table": "^8.0.0", - "axios": "^1.16.0", - "lucide-react": "^1.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "react-router-dom": "^7.0.0", - "react-virtualized": "^9.22.0", - "zod": "^4.4.0" - } } } } diff --git a/src/package.json b/src/package.json index 8b3e510..63320a2 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "@maks-it.com/webui", - "version": "0.4.5", + "version": "0.4.6", "description": "Shared contracts, utilities, and React components for MaksIT WebUI apps", "type": "module", "main": "./dist/index.cjs", @@ -106,5 +106,11 @@ "workerDirectory": [ "public" ] + }, + "allowScripts": { + "esbuild@0.27.7": true, + "esbuild@0.25.12": true, + "msw@2.15.0": true, + "unrs-resolver@1.12.2": true } } diff --git a/src/test/components/DataTable/dataTableScroll.test.ts b/src/test/components/DataTable/dataTableScroll.test.ts new file mode 100644 index 0000000..9a3bc79 --- /dev/null +++ b/src/test/components/DataTable/dataTableScroll.test.ts @@ -0,0 +1,59 @@ +import { dataTableScrollEdge, forcedScrollTopFor } from '@webui/components/components/DataTable/dataTableScroll' + +const overflowing = { + scrollTop: 0, + clientHeight: 200, + scrollHeight: 800, + hasNextPage: true, + hasPreviousPage: true, + pendingReset: false, +} + +describe('dataTableScrollEdge', () => { + it('loads the next page from the bottom and asks to jump to the top', () => { + expect(dataTableScrollEdge({ ...overflowing, scrollTop: 600 })).toEqual({ + type: 'next', + reset: 'top', + }) + }) + + it('loads the previous page from the top and asks to jump to the bottom', () => { + expect(dataTableScrollEdge({ ...overflowing, scrollTop: 1 })).toEqual({ + type: 'previous', + reset: 'bottom', + }) + }) + + it('ignores edges while a page reset is in flight', () => { + expect(dataTableScrollEdge({ ...overflowing, scrollTop: 600, pendingReset: true })).toEqual({ + type: 'none', + }) + }) + + it('ignores edges when the page does not overflow', () => { + expect(dataTableScrollEdge({ + ...overflowing, + scrollTop: 0, + clientHeight: 400, + scrollHeight: 400, + })).toEqual({ type: 'none' }) + }) + + it('does not request a page that does not exist', () => { + expect(dataTableScrollEdge({ + ...overflowing, + scrollTop: 600, + hasNextPage: false, + })).toEqual({ type: 'none' }) + }) +}) + +describe('forcedScrollTopFor', () => { + it('maps a top reset to scrollTop 0', () => { + expect(forcedScrollTopFor('top')).toBe(0) + }) + + it('maps a bottom reset to a clamped large scrollTop', () => { + expect(forcedScrollTopFor('bottom')).toBe(Number.MAX_SAFE_INTEGER) + }) +}) diff --git a/utils/engines/release/Invoke-ReleasePackage.ps1 b/utils/engines/release/Invoke-ReleasePackage.ps1 index fde2b43..887e7a7 100644 --- a/utils/engines/release/Invoke-ReleasePackage.ps1 +++ b/utils/engines/release/Invoke-ReleasePackage.ps1 @@ -35,6 +35,7 @@ else { } $configuredPlugins = Get-ConfiguredPlugins -Settings $settings +Initialize-RepoUtilsVaultSecrets -Settings $settings -Plugins $configuredPlugins $releaseBanner = if ($null -ne $releaseExtension) { $releaseExtension.StepBanner @@ -81,7 +82,8 @@ else { } $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot - if (-not $pluginSucceeded) { + # Exact $true only: polluted arrays (CLI stdout + $false) are truthy under -not. + if ($pluginSucceeded -ne $true) { $releaseHadPluginFailures = $true break } diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json index b944584..ba09d74 100644 --- a/utils/engines/release/scriptSettings.json +++ b/utils/engines/release/scriptSettings.json @@ -2,6 +2,8 @@ "$schema": "https://json-schema.org/draft-07/schema", "title": "Release Package Script Settings", "description": "maksit-webui: npm workspace publish (@maks-it.com/webui). Semver from NpmReleaseVersion (src/package.json).", + "repoUtilsSecretsShared": "RepoUtilsSecretsShared", + "repoUtilsSecrets": "RepoUtilsSecrets", "plugins": [ { "name": "NpmReleaseVersion", @@ -46,7 +48,7 @@ "name": "GitHub", "stageLabel": "release", "enabled": true, - "githubSecret": "GitHub", + "githubSecret": "GitClone", "repository": "https://github.com/MAKS-IT-COM/maksit-webui", "releaseNotesFile": "..\\..\\..\\CHANGELOG.md", "releaseTitlePattern": "Release {version}" diff --git a/utils/engines/test/Invoke-TestEngine.ps1 b/utils/engines/test/Invoke-TestEngine.ps1 index 7b3eb90..7d06532 100644 --- a/utils/engines/test/Invoke-TestEngine.ps1 +++ b/utils/engines/test/Invoke-TestEngine.ps1 @@ -32,7 +32,8 @@ $testHadPluginFailures = $false foreach ($plugin in $configuredPlugins) { $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -EngineDirectory $scriptDir - if (-not $pluginSucceeded) { + # Exact $true only: polluted arrays (CLI stdout + $false) are truthy under -not. + if ($pluginSucceeded -ne $true) { $testHadPluginFailures = $true break } diff --git a/utils/modules/ChangelogSupport.psm1 b/utils/modules/ChangelogSupport.psm1 index feb7afa..211c83a 100644 --- a/utils/modules/ChangelogSupport.psm1 +++ b/utils/modules/ChangelogSupport.psm1 @@ -6,16 +6,65 @@ Keep a Changelog header parsing and section extraction. .DESCRIPTION - Supports only the standard Keep a Changelog version line: + Supports Keep a Changelog version lines and shared SemVer checks, including prerelease: ## [1.0.0] - 2026-05-24 + ## [0.1.0-alpha.1] - 2026-08-21 + ## [0.1.0-beta.1] - 2026-08-21 + ## [0.1.0-rc.1] - 2026-08-21 #> +function Get-ChangelogSemverPattern { + return '\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?' +} + +function Test-ReleaseSemver { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Version + ) + + if ([string]::IsNullOrWhiteSpace($Version)) { + return $false + } + + return [bool]($Version -match ('^' + (Get-ChangelogSemverPattern) + '$')) +} + +function Test-ReleaseSemverPrerelease { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Version + ) + + return (Test-ReleaseSemver -Version $Version) -and ($Version -match '-') +} + +function Get-ReleaseSemverPrereleaseLabel { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Version + ) + + if (-not (Test-ReleaseSemverPrerelease -Version $Version)) { + return $null + } + + if ($Version -match '^\d+\.\d+\.\d+-([A-Za-z][0-9A-Za-z]*)') { + return $Matches[1].ToLowerInvariant() + } + + return 'next' +} + function Get-ChangelogVersionHeaderPattern { - return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$' + return '(?m)^##\s+\[(' + (Get-ChangelogSemverPattern) + ')\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$' } function Get-ChangelogNextVersionHeaderPattern { - return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$' + return '(?m)^##\s+\[' + (Get-ChangelogSemverPattern) + '\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$' } function Get-LatestChangelogVersion { @@ -53,4 +102,4 @@ function Get-ChangelogReleaseNotesSection { return $match.Value.Trim() } -Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection +Export-ModuleMember -Function Get-ChangelogSemverPattern, Test-ReleaseSemver, Test-ReleaseSemverPrerelease, Get-ReleaseSemverPrereleaseLabel, Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection diff --git a/utils/modules/Engine/EngineContext.psm1 b/utils/modules/Engine/EngineContext.psm1 index 3b94fa9..8fceadd 100644 --- a/utils/modules/Engine/EngineContext.psm1 +++ b/utils/modules/Engine/EngineContext.psm1 @@ -321,6 +321,37 @@ function Get-EngineState { return $null } +function Add-ReleaseAssetPaths { + param( + [Parameter(Mandatory = $true)] + [psobject]$Context, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]]$Path + ) + + $list = [System.Collections.Generic.List[string]]::new() + $existing = Get-EngineFact -Context $Context -Namespace 'release' -Name 'assetPaths' -Default @() -LegacyProperty @('releaseAssetPaths') + foreach ($item in @($existing)) { + $value = [string]$item + if (-not [string]::IsNullOrWhiteSpace($value) -and -not $list.Contains($value)) { + $list.Add($value) + } + } + + foreach ($item in @($Path)) { + $value = [string]$item + if ([string]::IsNullOrWhiteSpace($value) -or $list.Contains($value)) { + continue + } + + $list.Add($value) + } + + Set-EngineFact -Context $Context -Namespace 'release' -Name 'assetPaths' -Value @($list) -Overwrite Replace -LegacyProperty 'releaseAssetPaths' +} + Export-ModuleMember -Function ` Resolve-RelativePaths, ` Initialize-EngineFactsBag, ` @@ -329,4 +360,5 @@ Export-ModuleMember -Function ` Test-EngineFact, ` Set-EngineState, ` Add-EnginePublishCompletion, ` + Add-ReleaseAssetPaths, ` Get-EngineState diff --git a/utils/modules/Engine/Import-EngineModules.ps1 b/utils/modules/Engine/Import-EngineModules.ps1 index a557a58..bc9aae9 100644 --- a/utils/modules/Engine/Import-EngineModules.ps1 +++ b/utils/modules/Engine/Import-EngineModules.ps1 @@ -15,6 +15,7 @@ function Import-EngineModules { (Join-Path $modulesDir 'ScriptConfig.psm1'), (Join-Path $modulesDir 'Logging.psm1'), (Join-Path $engineModuleDir 'PluginSupport.psm1'), + (Join-Path $engineModuleDir 'VaultSupport.psm1'), (Join-Path $engineModuleDir 'EngineContext.psm1') ) diff --git a/utils/modules/Engine/PluginSupport.psm1 b/utils/modules/Engine/PluginSupport.psm1 index e2cf85e..2b808d4 100644 --- a/utils/modules/Engine/PluginSupport.psm1 +++ b/utils/modules/Engine/PluginSupport.psm1 @@ -33,6 +33,7 @@ function Test-IsEngineRuntimeModuleName { 'TestRunner', 'EngineContext', 'PluginSupport', + 'VaultSupport', 'ReleaseSupport', 'TestSupport' ), @@ -53,7 +54,7 @@ function Get-PluginDependencyGroupDirectories { } # Prefer Shared (helpers), then stock host groups; any other plugins/{Group}/ is discovered. - $preferred = @('Shared', 'Platform', 'DotNet', 'Npm') + $preferred = @('Shared', 'Platform', 'DotNet', 'Npm', 'Desktop') $dirs = [System.Collections.Generic.List[string]]::new() foreach ($name in $preferred) { $path = Join-Path $PluginsRoot $name @@ -293,129 +294,586 @@ function Test-PluginMutatesRemote { return $false } -function Get-SecretEnvironmentValue { +function Get-RepoUtilsEnvironmentVariable { <# .SYNOPSIS - Reads a secret value from an environment variable by logical name. + Reads a named environment variable from Process, then Windows User, then Machine. .DESCRIPTION - Plugins never store secret material in scriptSettings.json. Settings hold a - logical name (e.g. "GitHub", "NuGet"); the process environment variable with - that same name must be set before the engine runs. - - .PARAMETER Name - Logical secret name — also the environment variable name to read. - - .OUTPUTS - System.String. The environment variable value, or $null when unset. - - .EXAMPLE - $token = Get-SecretEnvironmentValue -Name 'GitHub' + Process wins (CICD sandbox inject, `$env:Name`, explicit session values). When the + current process was started before a User-level pack was set, User/Machine still + apply so laptop releases do not require a new shell. An explicit process value — + including empty JSON `{}` — shadows User/Machine. #> param( [Parameter(Mandatory = $true)] [string]$Name ) - return [Environment]::GetEnvironmentVariable($Name) -} + if ([string]::IsNullOrWhiteSpace($Name)) { + throw "Environment variable name is required." + } -function Resolve-PluginSecretName { - <# - .SYNOPSIS - Resolves a logical secret name from a plugin's scriptSettings entry. + $processValue = [Environment]::GetEnvironmentVariable($Name, 'Process') + # Empty string is what SetEnvironmentVariable($null, Process) leaves behind; + # treat it as unset so Windows User packs still apply. Explicit '{}' shadows User. + if (-not [string]::IsNullOrWhiteSpace($processValue)) { + return $processValue + } - .DESCRIPTION - Reads a string property such as githubSecret / nugetSecret / npmSecret / - containerRegistrySecret from the plugin settings object. Returns $null when - the property is missing or blank. + foreach ($target in @('User', 'Machine')) { + try { + $value = [Environment]::GetEnvironmentVariable($Name, $target) + } + catch { + continue + } - .PARAMETER PluginSettings - Plugin settings object from scriptSettings.json (the enabled plugin entry). - - .PARAMETER PropertyName - Settings property that holds the logical secret name (e.g. 'githubSecret'). - - .OUTPUTS - System.String. Trimmed logical secret name, or $null. - - .EXAMPLE - $name = Resolve-PluginSecretName -PluginSettings $plugin -PropertyName 'nugetSecret' - $key = Get-SecretEnvironmentValue -Name $name - #> - param( - [Parameter(Mandatory = $true)] - $PluginSettings, - - [Parameter(Mandatory = $true)] - [string]$PropertyName - ) - - if ($PluginSettings.PSObject.Properties.Name -contains $PropertyName) { - $value = [string]$PluginSettings.$PropertyName if (-not [string]::IsNullOrWhiteSpace($value)) { - return $value.Trim() + return $value } } return $null } +function Get-RepoUtilsSecretsEnvNames { + <# + .SYNOPSIS + Reads declared pack environment variable names from scriptSettings / engine context. + #> + param( + [Parameter(Mandatory = $false)] + $Settings + ) + + $sharedName = $null + $packName = $null + if ($null -ne $Settings) { + if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecretsShared') { + $sharedName = [string]$Settings.repoUtilsSecretsShared + } + + if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecrets') { + $packName = [string]$Settings.repoUtilsSecrets + } + } + + $sharedName = if ($null -eq $sharedName) { '' } else { $sharedName.Trim() } + $packName = if ($null -eq $packName) { '' } else { $packName.Trim() } + if ([string]::IsNullOrWhiteSpace($sharedName) -or [string]::IsNullOrWhiteSpace($packName)) { + throw "scriptSettings.json must declare repoUtilsSecretsShared and repoUtilsSecrets (environment variable names, e.g. RepoUtilsSecretsShared / RepoUtilsSecrets)." + } + + return [pscustomobject]@{ + SharedEnv = $sharedName + PackEnv = $packName + } +} + +function ConvertFrom-RepoUtilsSecretsPackJson { + <# + .SYNOPSIS + Parses a RepoUtilsSecrets JSON object. Empty input is {}. Bare non-JSON throws. + #> + param( + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$Raw, + + [Parameter(Mandatory = $true)] + [string]$SourceName + ) + + if ([string]::IsNullOrWhiteSpace($Raw)) { + return [pscustomobject]@{} + } + + $trimmed = $Raw.Trim() + if (-not $trimmed.StartsWith('{')) { + throw "${SourceName} must be a JSON object (RepoUtilsSecrets pack), not a bare string." + } + + try { + $parsed = $trimmed | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "${SourceName} is not valid JSON: $($_.Exception.Message)" + } + + if ($null -eq $parsed -or $parsed -is [System.Collections.IEnumerable]) { + throw "${SourceName} JSON must be an object." + } + + return $parsed +} + +function ConvertTo-OrdinalPropertyMap { + param($Object) + + $map = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + if ($null -eq $Object) { + return $map + } + + if ($Object -is [System.Collections.IDictionary]) { + foreach ($key in @($Object.Keys)) { + $name = [string]$key + if ([string]::IsNullOrWhiteSpace($name)) { + continue + } + + $map[$name] = $Object[$key] + } + + return $map + } + + foreach ($property in $Object.PSObject.Properties) { + if ($property.MemberType -notin @('NoteProperty', 'Property')) { + continue + } + + $map[[string]$property.Name] = $property.Value + } + + return $map +} + +function ConvertFrom-OrdinalPropertyMap { + param( + [Parameter(Mandatory = $true)] + [System.Collections.Generic.Dictionary[string, object]]$Map + ) + + $properties = [ordered]@{} + foreach ($key in $Map.Keys) { + $properties[$key] = $Map[$key] + } + + return [pscustomobject]$properties +} + +function Merge-RepoUtilsSecretsPackObjects { + <# + .SYNOPSIS + Appsettings-style merge: org-pack first, slug overlay. Nested merge for any object-valued slot (typically ContainerRegistry). + #> + param( + $Base, + $Overlay + ) + + $result = ConvertTo-OrdinalPropertyMap -Object $Base + $overlayMap = ConvertTo-OrdinalPropertyMap -Object $Overlay + foreach ($key in @($overlayMap.Keys)) { + if (-not (Test-ContainerRegistryCatalogKey -Key $key)) { + throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)." + } + + $overlayValue = $overlayMap[$key] + $overlayIsObject = ($null -ne $overlayValue) -and -not ($overlayValue -is [string]) -and -not ($overlayValue -is [ValueType]) -and -not ($overlayValue -is [System.Collections.IEnumerable]) + if ($overlayIsObject) { + $baseCatalog = $null + if ($result.ContainsKey($key)) { + $baseCatalog = $result[$key] + } + + $mergedCatalog = ConvertTo-OrdinalPropertyMap -Object $baseCatalog + $overlayCatalogMap = ConvertTo-OrdinalPropertyMap -Object $overlayValue + foreach ($catalogKey in @($overlayCatalogMap.Keys)) { + if (-not (Test-ContainerRegistryCatalogKey -Key $catalogKey)) { + throw "Catalog key '$catalogKey' in pack slot '$key' must be PascalCase (e.g. Harbor, InCluster)." + } + + $mergedCatalog[$catalogKey] = $overlayCatalogMap[$catalogKey] + } + + $result[$key] = ConvertFrom-OrdinalPropertyMap -Map $mergedCatalog + continue + } + + $result[$key] = $overlayValue + } + + foreach ($key in @($result.Keys)) { + if (-not (Test-ContainerRegistryCatalogKey -Key $key)) { + throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)." + } + } + + return ConvertFrom-OrdinalPropertyMap -Map $result +} + +function Get-MergedRepoUtilsSecretsPack { + <# + .SYNOPSIS + Merges $env:RepoUtilsSecretsShared then $env:RepoUtilsSecrets (names from settings). + #> + param( + [Parameter(Mandatory = $false)] + $Settings + ) + + $names = Get-RepoUtilsSecretsEnvNames -Settings $Settings + $sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv + $packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv + $sharedObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv + $packObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv + return Merge-RepoUtilsSecretsPackObjects -Base $sharedObject -Overlay $packObject +} + +function Get-RepoUtilsSecretSlot { + <# + .SYNOPSIS + Reads a scalar pack slot (GitClone, NuGet, Npm, CosignKey, …) after merge. + #> + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $false)] + $Settings, + + [switch]$AllowMissing + ) + + if ([string]::IsNullOrWhiteSpace($Name) -or -not (Test-ContainerRegistryCatalogKey -Key $Name)) { + throw "RepoUtilsSecrets slot '$Name' must be PascalCase (e.g. GitClone, NuGet)." + } + + $merged = Get-MergedRepoUtilsSecretsPack -Settings $Settings + $match = $null + foreach ($property in $merged.PSObject.Properties) { + if ($property.MemberType -notin @('NoteProperty', 'Property')) { + continue + } + + if ([string]::Equals([string]$property.Name, $Name, [System.StringComparison]::Ordinal)) { + $match = $property + break + } + } + + if ($null -eq $match) { + if ($AllowMissing) { + return $null + } + + throw "RepoUtilsSecrets slot '$Name' is missing after merging org-pack and slug-pack." + } + + $value = $match.Value + if ($value -is [string] -or $null -eq $value -or $value -is [ValueType]) { + $text = if ($null -eq $value) { '' } else { [string]$value } + if ([string]::IsNullOrWhiteSpace($text) -and -not $AllowMissing) { + throw "RepoUtilsSecrets slot '$Name' is empty." + } + + if ([string]::IsNullOrWhiteSpace($text)) { + return $null + } + + return $text + } + + throw "RepoUtilsSecrets slot '$Name' must be a string (nested maps are only allowed for ContainerRegistry)." +} + +function Copy-RepoUtilsSecretsEnvNamesToContext { + param( + [Parameter(Mandatory = $true)] + $Context, + + [Parameter(Mandatory = $false)] + $Settings + ) + + if ($null -eq $Settings) { + return $Context + } + + foreach ($name in @('repoUtilsSecretsShared', 'repoUtilsSecrets', 'vaultRepoUtilsApplication')) { + if ($Settings.PSObject.Properties.Name -contains $name -and -not [string]::IsNullOrWhiteSpace([string]$Settings.$name)) { + $Context | Add-Member -NotePropertyName $name -NotePropertyValue ([string]$Settings.$name).Trim() -Force + } + } + + return $Context +} + +function Test-ContainerRegistryCatalogKey { + <# + .SYNOPSIS + True when Key is PascalCase (Harbor, InCluster), matching env-slot names. + #> + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Key + ) + + return $Key -cmatch '^[A-Z][A-Za-z0-9]*$' +} + +function Resolve-PluginSecretName { + <# + .SYNOPSIS + Reads a plugin setting that names a RepoUtilsSecrets pack subkey. + + .DESCRIPTION + Settings such as githubSecret / nugetSecret / npmSecret / containerRegistrySecret / + cosignKeySecret hold the PascalCase pack slot to read (e.g. GitClone, NuGet). + They are not environment variable names. + + .PARAMETER PluginSettings + Plugin settings object from scriptSettings.json. + + .PARAMETER PropertyName + Settings property that holds the pack subkey name (e.g. 'githubSecret'). + + .PARAMETER PluginDisplayName + Plugin name used in required/validation errors. + + .PARAMETER Required + Throw when the property is missing or blank. + #> + param( + [Parameter(Mandatory = $true)] + $PluginSettings, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $false)] + [string]$PluginDisplayName, + + [switch]$Required + ) + + $display = if ([string]::IsNullOrWhiteSpace($PluginDisplayName)) { 'Plugin' } else { $PluginDisplayName } + $value = $null + if ($null -ne $PluginSettings -and $PluginSettings.PSObject.Properties.Name -contains $PropertyName) { + $raw = [string]$PluginSettings.$PropertyName + if (-not [string]::IsNullOrWhiteSpace($raw)) { + $value = $raw.Trim() + } + } + + if ([string]::IsNullOrWhiteSpace($value)) { + if ($Required) { + throw "$display requires '$PropertyName' (PascalCase pack subkey, e.g. GitClone, NuGet, ContainerRegistry)." + } + + return $null + } + + if (-not (Test-ContainerRegistryCatalogKey -Key $value)) { + throw "$display '$PropertyName' '$value' must be PascalCase (e.g. GitClone, NuGet, ContainerRegistry)." + } + + return $value +} + +function Assert-RetiredContainerRegistrySettingsAbsent { + <# + .SYNOPSIS + Throws when obsolete per-registry secret settings are present. + #> + param( + $Object, + [Parameter(Mandatory = $true)] + [string]$Context + ) + + if ($null -eq $Object) { + return + } + + $retired = @( + 'imagesCredentialsSecret', + 'additionalImageRegistries', + 'additionalImageRegistryUrls', + 'additionalImagesCredentialsSecret', + 'helmOciCredentialsSecret' + ) + + foreach ($name in $retired) { + $present = $false + if ($Object -is [System.Collections.IDictionary]) { + $present = $Object.Contains($name) + } + elseif ($Object.PSObject.Properties.Name -contains $name) { + $present = $true + } + + if ($present) { + throw "${Context}: '$name' is not supported. Use the ContainerRegistry JSON catalog with PascalCase containerRegistryKey / helmRegistryKey." + } + } +} + +function ConvertFrom-RegistryCredentialPayload { + param( + [Parameter(Mandatory = $true)] + [string]$Payload, + + [Parameter(Mandatory = $true)] + [string]$ContextName + ) + + try { + $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload.Trim())) + } + catch { + throw "Failed to decode '$ContextName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" + } + + $parts = $decoded -split ':', 2 + if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { + throw "Decoded '$ContextName' must be in the form 'username:password'." + } + + return @{ User = $parts[0]; Password = $parts[1] } +} + +function Get-ContainerRegistryCatalogObject { + <# + .SYNOPSIS + Validates a PascalCase JSON map of Base64(username:password) values. + #> + param( + [Parameter(Mandatory = $true)] + $Catalog, + + [Parameter(Mandatory = $false)] + [string]$SourceName = 'ContainerRegistry' + ) + + if ($Catalog -is [string]) { + $raw = [string]$Catalog + $trimmed = $raw.Trim() + if (-not $trimmed.StartsWith('{')) { + throw "$SourceName must be a JSON catalog object { `"Harbor`": `"`", `"InCluster`": `"`" }." + } + + try { + $Catalog = $trimmed | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "$SourceName is not valid JSON: $($_.Exception.Message)" + } + } + + if ($null -eq $Catalog -or $Catalog -is [string] -or $Catalog -is [ValueType] -or $Catalog -is [System.Collections.IEnumerable]) { + throw "$SourceName JSON catalog must be an object of PascalCase keys to Base64(username:password)." + } + + $keyCount = 0 + foreach ($property in $Catalog.PSObject.Properties) { + if ($property.MemberType -notin @('NoteProperty', 'Property')) { + continue + } + + $keyCount++ + $keyName = [string]$property.Name + if (-not (Test-ContainerRegistryCatalogKey -Key $keyName)) { + throw "Catalog key '$keyName' in '$SourceName' must be PascalCase (e.g. Harbor, InCluster)." + } + } + + if ($keyCount -eq 0) { + throw "$SourceName JSON catalog has no PascalCase keys." + } + + return $Catalog +} + function Get-RegistryCredentialsFromRuntime { <# .SYNOPSIS - Loads container-registry username/password from a logical secret name. + Loads container-registry username/password from the merged RepoUtilsSecrets pack. .DESCRIPTION - Looks up the environment variable named by SecretName. The value must be - Base64(UTF8('username:password')). Used by registry login and image-pull - secret creation — never pass the password itself as a parameter. + Reads a nested catalog slot (named by -Slot, typically ContainerRegistry) after + org-pack + slug-pack merge. -Key (PascalCase, ordinal match) selects an entry. - .PARAMETER SecretName - Logical secret name (environment variable name), not a password or token. + .PARAMETER Key + PascalCase catalog key (e.g. Harbor). Required. + + .PARAMETER Slot + PascalCase pack subkey that holds the catalog object (from containerRegistrySecret). .PARAMETER SharedSettings - Optional engine shared context (reserved for callers that thread context). + Engine shared context (must declare repoUtilsSecretsShared / repoUtilsSecrets). .OUTPUTS Hashtable with User and Password keys (decoded credential material). - - .EXAMPLE - $creds = Get-RegistryCredentialsFromRuntime -SecretName 'ContainerRegistry' - # $creds.User / $creds.Password #> - # SecretName is a logical env-var name from scriptSettings, not a password value. - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSAvoidUsingPlainTextForPassword', - 'SecretName', - Justification = 'Logical secret name for env lookup (Base64 username:password); not a credential value.' - )] param( [Parameter(Mandatory = $true)] - [string]$SecretName, + [string]$Key, + + [Parameter(Mandatory = $true)] + [string]$Slot, [Parameter(Mandatory = $false)] [psobject]$SharedSettings ) - $raw = Get-SecretEnvironmentValue -Name $SecretName - if ([string]::IsNullOrWhiteSpace($raw)) { - throw "Environment variable '$SecretName' is not set." + if ([string]::IsNullOrWhiteSpace($Slot) -or -not (Test-ContainerRegistryCatalogKey -Key $Slot)) { + throw "containerRegistrySecret '$Slot' must be PascalCase (e.g. ContainerRegistry)." } - try { - $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) - } - catch { - throw "Failed to decode '$SecretName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" + if ([string]::IsNullOrWhiteSpace($Key)) { + throw "Pass -Key (PascalCase, e.g. Harbor) to select an entry in pack slot '$Slot'." } - $parts = $decoded -split ':', 2 - if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { - throw "Decoded '$SecretName' must be in the form 'username:password'." + if (-not (Test-ContainerRegistryCatalogKey -Key $Key)) { + throw "containerRegistryKey '$Key' must be PascalCase (e.g. Harbor, InCluster)." } - return @{ User = $parts[0]; Password = $parts[1] } + $merged = Get-MergedRepoUtilsSecretsPack -Settings $SharedSettings + $catalogProperty = $null + foreach ($property in $merged.PSObject.Properties) { + if ($property.MemberType -notin @('NoteProperty', 'Property')) { + continue + } + + if ([string]::Equals([string]$property.Name, $Slot, [System.StringComparison]::Ordinal)) { + $catalogProperty = $property + break + } + } + + if ($null -eq $catalogProperty -or $null -eq $catalogProperty.Value) { + throw "RepoUtilsSecrets slot '$Slot' is missing after merging org-pack and slug-pack." + } + + $catalog = Get-ContainerRegistryCatalogObject -Catalog $catalogProperty.Value -SourceName $Slot + + $match = $null + foreach ($property in $catalog.PSObject.Properties) { + if ($property.MemberType -notin @('NoteProperty', 'Property')) { + continue + } + + if ([string]::Equals([string]$property.Name, $Key, [System.StringComparison]::Ordinal)) { + $match = $property + break + } + } + + if ($null -eq $match) { + throw "Catalog key '$Key' was not found in '$Slot' (ordinal PascalCase match)." + } + + $payload = [string]$match.Value + if ([string]::IsNullOrWhiteSpace($payload)) { + throw "Catalog key '$Key' in '$Slot' is empty." + } + + return ConvertFrom-RegistryCredentialPayload -Payload $payload -ContextName "$Slot.$Key" } function Resolve-EngineDirectoryFromSharedSettings { @@ -596,7 +1054,7 @@ function Resolve-PluginModulePath { $candidatePaths = [System.Collections.Generic.List[string]]::new() $candidatePaths.Add((Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName)) - $preferredGroups = @('Platform', 'DotNet', 'Npm') + $preferredGroups = @('Platform', 'DotNet', 'Npm', 'Desktop') $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $preferredGroups[0]) $pluginFileName)) if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) { @@ -737,12 +1195,16 @@ function Invoke-ConfiguredPlugin { $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..." + # Sink plugin success-stream output to the host so it cannot pollute this + # function's return value. Otherwise `return $false` after CLI stdout becomes + # @("helm-line…", $false), which is truthy under `if (-not $result)` and + # causes RELEASE COMPLETE / exit 0 after a failed plugin. try { $moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop $invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop $pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings - & $invokeCommand -Settings $pluginSettings + & $invokeCommand -Settings $pluginSettings | ForEach-Object { Write-Host $_ } Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed." return $true } @@ -752,4 +1214,4 @@ function Invoke-ConfiguredPlugin { } } -Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Resolve-PluginSecretName, Get-SecretEnvironmentValue, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin +Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Get-RepoUtilsEnvironmentVariable, Get-RepoUtilsSecretsEnvNames, ConvertFrom-RepoUtilsSecretsPackJson, Merge-RepoUtilsSecretsPackObjects, Get-MergedRepoUtilsSecretsPack, Get-RepoUtilsSecretSlot, Copy-RepoUtilsSecretsEnvNamesToContext, Resolve-PluginSecretName, Test-ContainerRegistryCatalogKey, Assert-RetiredContainerRegistrySettingsAbsent, Get-ContainerRegistryCatalogObject, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin diff --git a/utils/modules/Engine/ReleaseSupport.psm1 b/utils/modules/Engine/ReleaseSupport.psm1 index 0de88e4..6dc2ffb 100644 --- a/utils/modules/Engine/ReleaseSupport.psm1 +++ b/utils/modules/Engine/ReleaseSupport.psm1 @@ -213,6 +213,7 @@ function New-EngineContext { skipPublishPlugins = $false facts = [ordered]@{} } + $context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings $versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir $version = [string](Get-EngineState -Context $context -Name 'version' -Required) diff --git a/utils/modules/Engine/TestSupport.psm1 b/utils/modules/Engine/TestSupport.psm1 index b49b60b..da06af2 100644 --- a/utils/modules/Engine/TestSupport.psm1 +++ b/utils/modules/Engine/TestSupport.psm1 @@ -38,6 +38,7 @@ function New-EngineContext { utilsDir = $SrcDir facts = [ordered]@{} } + $context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings $expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue if ($expandContext) { diff --git a/utils/modules/Engine/VaultSupport.psm1 b/utils/modules/Engine/VaultSupport.psm1 new file mode 100644 index 0000000..c4226d3 --- /dev/null +++ b/utils/modules/Engine/VaultSupport.psm1 @@ -0,0 +1,373 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Test-RepoUtilsUseVaultEnabled { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + if ($null -eq $Settings -or -not ($Settings.PSObject.Properties.Name -contains 'useVault')) { + return $false + } + + $value = $Settings.useVault + if ($value -is [bool]) { + return $value + } + + return [string]$value -eq 'true' +} + +function ConvertFrom-VaultConnectionSecret { + param( + [Parameter(Mandatory = $true)] + [string]$Raw + ) + + $trimmed = $Raw.Trim() + $separator = $trimmed.IndexOf('|') + if ($separator -lt 1 -or $separator -ge ($trimmed.Length - 1)) { + throw "Vault connection must be 'baseAddress|apiKey' (pipe separator). Example: http://172.16.0.14|your-key" + } + + $baseAddress = $trimmed.Substring(0, $separator).Trim().TrimEnd('/') + $apiKey = $trimmed.Substring($separator + 1).Trim() + if ([string]::IsNullOrWhiteSpace($baseAddress) -or [string]::IsNullOrWhiteSpace($apiKey)) { + throw "Vault connection is missing base address or API key." + } + + return [pscustomobject]@{ + BaseAddress = $baseAddress + ApiKey = $apiKey + } +} + +function Get-RepoUtilsVaultScope { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + $organization = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_ORGANIZATION') + if ([string]::IsNullOrWhiteSpace($organization) -and ($Settings.PSObject.Properties.Name -contains 'vaultOrganization')) { + $organization = [string]$Settings.vaultOrganization + } + + $application = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_APPLICATION') + if ([string]::IsNullOrWhiteSpace($application) -and ($Settings.PSObject.Properties.Name -contains 'vaultApplication')) { + $application = [string]$Settings.vaultApplication + } + + $organization = if ($null -eq $organization) { '' } else { $organization.Trim() } + $application = if ($null -eq $application) { '' } else { $application.Trim() } + + if ([string]::IsNullOrWhiteSpace($organization) -or [string]::IsNullOrWhiteSpace($application)) { + throw "useVault is true but vault organization/application are not set. Set vaultOrganization/vaultApplication in scriptSettings.json or MAKSIT_VAULT_ORGANIZATION / MAKSIT_VAULT_APPLICATION." + } + + return [pscustomobject]@{ + Organization = $organization + Application = $application + } +} + +function Get-RepoUtilsVaultConnectionSecretName { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + if (($Settings.PSObject.Properties.Name -contains 'vaultConnectionSecret') -and + -not [string]::IsNullOrWhiteSpace([string]$Settings.vaultConnectionSecret)) { + return ([string]$Settings.vaultConnectionSecret).Trim() + } + + return 'MAKSIT_VAULT' +} + +function Get-RepoUtilsVaultOrgPackApplicationName { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + $application = $null + if ($Settings.PSObject.Properties.Name -contains 'vaultRepoUtilsApplication') { + $application = [string]$Settings.vaultRepoUtilsApplication + } + + $application = if ($null -eq $application) { '' } else { $application.Trim() } + if ([string]::IsNullOrWhiteSpace($application)) { + throw "useVault is true but vaultRepoUtilsApplication is not set in scriptSettings.json (Vault application for the org-pack, e.g. Shared)." + } + + return $application +} + +function Get-VaultNameFilterExpression { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $escaped = $Name.Replace('\', '\\').Replace('"', '\"') + return "Name == `"$escaped`"" +} + +function Import-RepoUtilsVaultClientModule { + $modulePath = [Environment]::GetEnvironmentVariable('MAKSIT_VAULT_MODULE_PATH') + if (-not [string]::IsNullOrWhiteSpace($modulePath)) { + Import-Module $modulePath -Force -ErrorAction Stop + return + } + + if (Get-Command Connect-Vault -ErrorAction SilentlyContinue) { + return + } + + Import-Module 'MaksIT.Vault.Client.PowerShell' -ErrorAction SilentlyContinue +} + +function Find-RepoUtilsVaultSecretMatch { + param( + [Parameter(Mandatory = $true)] + [string]$OrganizationName, + + [Parameter(Mandatory = $true)] + [string]$ApplicationName, + + [Parameter(Mandatory = $true)] + [string]$SecretName, + + [Parameter(Mandatory = $true)] + [pscustomobject]$Connection, + + [Parameter(Mandatory = $true)] + [ValidateSet('Cmdlet', 'Rest')] + [string]$Mode + ) + + $orgFilter = Get-VaultNameFilterExpression -Name $OrganizationName + $appFilter = Get-VaultNameFilterExpression -Name $ApplicationName + $pageNumber = 1 + + while ($true) { + $items = @() + $hasNext = $false + + if ($Mode -eq 'Cmdlet') { + $response = Search-VaultSecrets -PageNumber $pageNumber -PageSize 100 ` + -OrganizationFilters $orgFilter -ApplicationFilters $appFilter + if ($null -ne $response -and $null -ne $response.Items) { + $items = @($response.Items) + } + if ($null -ne $response) { + $hasNext = [bool]$response.HasNextPage + } + } + else { + $body = @{ + pageNumber = $pageNumber + pageSize = 100 + organizationFilters = $orgFilter + applicationFilters = $appFilter + } | ConvertTo-Json -Compress + $uri = "$($Connection.BaseAddress)/api/vault/secrets" + $headers = @{ 'X-API-KEY' = $Connection.ApiKey } + $response = Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType 'application/json' -Body $body + if ($null -ne $response.items) { + $items = @($response.items) + } + elseif ($null -ne $response.Items) { + $items = @($response.Items) + } + $hasNext = [bool]($response.hasNextPage) + if (-not $hasNext) { + $hasNext = [bool]($response.HasNextPage) + } + } + + foreach ($item in $items) { + $itemName = [string]$(if ($item.PSObject.Properties.Name -contains 'Name') { $item.Name } else { $item.name }) + if ([string]::Equals($itemName, $SecretName, [System.StringComparison]::Ordinal)) { + return $item + } + } + + if (-not $hasNext) { + break + } + + $pageNumber++ + } + + return $null +} + +function Get-RepoUtilsVaultSecretValue { + param( + [Parameter(Mandatory = $true)] + $Match, + + [Parameter(Mandatory = $true)] + [pscustomobject]$Connection, + + [Parameter(Mandatory = $true)] + [ValidateSet('Cmdlet', 'Rest')] + [string]$Mode + ) + + $organizationId = if ($Match.PSObject.Properties.Name -contains 'OrganizationId') { $Match.OrganizationId } else { $Match.organizationId } + $applicationId = if ($Match.PSObject.Properties.Name -contains 'ApplicationId') { $Match.ApplicationId } else { $Match.applicationId } + $secretId = if ($Match.PSObject.Properties.Name -contains 'Id') { $Match.Id } else { $Match.id } + + if ($Mode -eq 'Cmdlet') { + $version = Get-VaultSecret -OrganizationId $organizationId -ApplicationId $applicationId -SecretId $secretId -SecretVersion 'current' + if ($null -eq $version -or [string]::IsNullOrWhiteSpace([string]$version.Value)) { + return $null + } + return [string]$version.Value + } + + $uri = "$($Connection.BaseAddress)/api/vault/organization/$organizationId/application/$applicationId/secret/$secretId" + + '?secretVersion=current' + $headers = @{ 'X-API-KEY' = $Connection.ApiKey } + $version = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers + $value = if ($version.PSObject.Properties.Name -contains 'Value') { $version.Value } else { $version.value } + if ([string]::IsNullOrWhiteSpace([string]$value)) { + return $null + } + return [string]$value +} + +function Resolve-RepoUtilsVaultSecretValue { + param( + [Parameter(Mandatory = $true)] + [string]$OrganizationName, + + [Parameter(Mandatory = $true)] + [string]$ApplicationName, + + [Parameter(Mandatory = $true)] + [string]$SecretName, + + [Parameter(Mandatory = $true)] + [pscustomobject]$Connection, + + [Parameter(Mandatory = $true)] + [string]$Mode + ) + + $match = Find-RepoUtilsVaultSecretMatch ` + -OrganizationName $OrganizationName ` + -ApplicationName $ApplicationName ` + -SecretName $SecretName ` + -Connection $Connection ` + -Mode $Mode + if ($null -eq $match) { + return $null + } + + $value = Get-RepoUtilsVaultSecretValue -Match $match -Connection $Connection -Mode $Mode + if ([string]::IsNullOrWhiteSpace($value)) { + return $null + } + + Write-Log -Level 'INFO' -Message "Resolved Vault secret '$SecretName' from $OrganizationName/$ApplicationName" + return $value +} + +function Initialize-RepoUtilsVaultSecrets { + param( + [Parameter(Mandatory = $true)] + $Settings, + + [Parameter(Mandatory = $true)] + $Plugins + ) + + if (-not (Test-RepoUtilsUseVaultEnabled -Settings $Settings)) { + return + } + + $names = Get-RepoUtilsSecretsEnvNames -Settings $Settings + $sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv + $packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv + $sharedPresent = -not [string]::IsNullOrWhiteSpace($sharedRaw) + $packPresent = -not [string]::IsNullOrWhiteSpace($packRaw) + if ($sharedPresent -and $packPresent) { + [void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv) + [void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv) + Write-Log -Level 'INFO' -Message "Vault mode: pack env vars '$($names.SharedEnv)' and '$($names.PackEnv)' already set; skipping Vault fetch." + return + } + + $connectionName = Get-RepoUtilsVaultConnectionSecretName -Settings $Settings + $raw = [Environment]::GetEnvironmentVariable($connectionName) + if ([string]::IsNullOrWhiteSpace($raw)) { + throw "useVault is true but environment variable '$connectionName' is not set (expected baseAddress|apiKey)." + } + + $connection = ConvertFrom-VaultConnectionSecret -Raw $raw + $scope = Get-RepoUtilsVaultScope -Settings $Settings + $orgPackApplication = Get-RepoUtilsVaultOrgPackApplicationName -Settings $Settings + + Write-Log -Level 'INFO' -Message "Vault mode: loading RepoUtilsSecrets packs for $($scope.Organization)/$orgPackApplication and $($scope.Organization)/$($scope.Application)" + + $mode = 'Rest' + try { + Import-RepoUtilsVaultClientModule + if (Get-Command Connect-Vault -ErrorAction SilentlyContinue) { + Connect-Vault -BaseAddress $connection.BaseAddress -ApiKey $connection.ApiKey + $mode = 'Cmdlet' + Write-Log -Level 'INFO' -Message 'Connected to Vault with MaksIT.Vault.Client.PowerShell' + } + else { + Write-Log -Level 'INFO' -Message 'MaksIT.Vault.Client.PowerShell not found; using Vault HTTP API' + } + } + catch { + Write-Log -Level 'INFO' -Message "Vault PowerShell module not loaded ($($_.Exception.Message)); using Vault HTTP API" + $mode = 'Rest' + } + + if (-not $sharedPresent) { + $sharedValue = Resolve-RepoUtilsVaultSecretValue ` + -OrganizationName $scope.Organization ` + -ApplicationName $orgPackApplication ` + -SecretName $names.SharedEnv ` + -Connection $connection ` + -Mode $mode + if ([string]::IsNullOrWhiteSpace($sharedValue)) { + $sharedValue = '{}' + Write-Log -Level 'INFO' -Message "Vault secret '$($names.SharedEnv)' was not found for $($scope.Organization)/$orgPackApplication; using empty org-pack." + } + + [Environment]::SetEnvironmentVariable($names.SharedEnv, $sharedValue, 'Process') + } + + if (-not $packPresent) { + $packValue = Resolve-RepoUtilsVaultSecretValue ` + -OrganizationName $scope.Organization ` + -ApplicationName $scope.Application ` + -SecretName $names.PackEnv ` + -Connection $connection ` + -Mode $mode + if ([string]::IsNullOrWhiteSpace($packValue)) { + $packValue = '{}' + Write-Log -Level 'INFO' -Message "Vault secret '$($names.PackEnv)' was not found for $($scope.Organization)/$($scope.Application); using empty slug-pack." + } + + [Environment]::SetEnvironmentVariable($names.PackEnv, $packValue, 'Process') + } +} + +Export-ModuleMember -Function @( + 'Test-RepoUtilsUseVaultEnabled', + 'ConvertFrom-VaultConnectionSecret', + 'Get-RepoUtilsVaultScope', + 'Get-RepoUtilsVaultConnectionSecretName', + 'Get-RepoUtilsVaultOrgPackApplicationName', + 'Initialize-RepoUtilsVaultSecrets' +) diff --git a/utils/modules/ExternalCommandSupport.psm1 b/utils/modules/ExternalCommandSupport.psm1 index 6c86899..f8420ef 100644 --- a/utils/modules/ExternalCommandSupport.psm1 +++ b/utils/modules/ExternalCommandSupport.psm1 @@ -107,7 +107,7 @@ function Invoke-ExternalCommand { if ($ThrowOnError -and $exitCode -ne 0) { $preview = ($output | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { [string]$_ } - } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 8) -join ' ' + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 12) -join ' ' if ([string]::IsNullOrWhiteSpace($preview)) { throw "External command '$Name' failed with exit code $exitCode." } diff --git a/utils/modules/TestRunner.psm1 b/utils/modules/TestRunner.psm1 index aac3308..d1e2eea 100644 --- a/utils/modules/TestRunner.psm1 +++ b/utils/modules/TestRunner.psm1 @@ -7,7 +7,7 @@ .DESCRIPTION Provides the Invoke-TestsWithCoverage function for running .NET tests - with Coverlet code coverage collection and parsing results. + with Microsoft.Testing.Platform and coverlet.MTP code coverage. .NOTES Author: MaksIT @@ -65,6 +65,20 @@ function Write-TestRunnerLogInternal { Write-Host $Message -ForegroundColor Gray } +function Get-CoberturaCoverageFiles { + param( + [Parameter(Mandatory = $true)] + [string]$ResultsDirectory + ) + + # coverlet.MTP: [prefix.]coverage.cobertura[.timestamp].xml + $files = @( + Get-ChildItem -Path $ResultsDirectory -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like '*coverage.cobertura*.xml' } + ) + return @($files | Sort-Object FullName -Unique) +} + function Invoke-TestsWithCoverage { <# .SYNOPSIS @@ -155,7 +169,7 @@ function Invoke-TestsWithCoverage { New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null if (-not $Silent) { - Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..." + Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage (Microsoft.Testing.Platform / coverlet.MTP)..." foreach ($d in $resolvedProjectDirs) { Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d" } @@ -164,11 +178,15 @@ function Invoke-TestsWithCoverage { foreach ($TestProjectDir in $resolvedProjectDirs) { Push-Location $TestProjectDir try { + $projectName = [System.IO.Path]::GetFileName($TestProjectDir) $dotnetArgs = @( "test" - "--collect:XPlat Code Coverage" "--results-directory", $ResultsDir "--verbosity", $(if ($Silent) { "quiet" } else { "normal" }) + "--coverlet" + "--coverlet-output-format", "cobertura" + "--coverlet-file-prefix", $projectName + "--coverlet-include", "[MaksIT.*]*" ) Import-ExternalCommandSupportInternal @@ -192,7 +210,7 @@ function Invoke-TestsWithCoverage { } } - $coverageFiles = @(Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Sort-Object FullName) + $coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDir) if ($coverageFiles.Count -eq 0) { return [PSCustomObject]@{ @@ -455,7 +473,7 @@ function Get-DotNetCoverageFromResultsDirectory { [switch]$Silent ) - $coverageFiles = @(Get-ChildItem -Path $ResultsDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName) + $coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDirectory) if ($coverageFiles.Count -eq 0) { return [PSCustomObject]@{ Success = $false @@ -566,7 +584,7 @@ function Get-CoverageFromResultsDirectory { } } - $hasDotNet = @(Get-ChildItem -Path $resolvedDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue).Count -gt 0 + $hasDotNet = @(Get-CoberturaCoverageFiles -ResultsDirectory $resolvedDirectory).Count -gt 0 $jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json' $hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf diff --git a/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 b/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 deleted file mode 100644 index f2f0ed6..0000000 --- a/utils/plugins/DotNet/DiscoverDotNetPackageArtifacts.psm1 +++ /dev/null @@ -1,69 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - Discovers existing .NET NuGet package artifacts. - -.DESCRIPTION - Scans artifactsDir for .nupkg/.snupkg matching the release version and populates shared - context (packageFile, symbolsPackageFile, releaseArchiveInputs) for downstream plugins. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' - Import-PluginDependency -ModuleName 'DotNetArtifactSupport' -RequiredCommand 'Resolve-DotNetPackageArtifacts' - Import-PluginDependency -ModuleName 'EngineContext' -RequiredCommand 'Set-EngineFact' - - $pluginSettings = $Settings - $sharedSettings = $Settings.context - $scriptDir = $sharedSettings.scriptDir - $version = $sharedSettings.version - - if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) { - $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir))) - Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory - Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory - } - else { - $artifactsDirectory = $sharedSettings.artifactsDirectory - } - - if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { - throw 'DiscoverDotNetPackageArtifacts requires artifactsDir in plugin settings or artifactsDirectory on shared context.' - } - - Write-Log -Level 'STEP' -Message "Discovering NuGet package artifacts in $artifactsDirectory ..." - $resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version - - Write-Log -Level 'OK' -Message " Package ready: $($resolved.PackageFile.FullName)" - if ($resolved.SymbolsPackageFile) { - Write-Log -Level 'OK' -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)" - } - else { - Write-Log -Level 'WARN' -Message " Symbols package (.snupkg) not found for version $version." - } - - Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile' - Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile' - Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' -} - -function Get-PluginMetadata { - [pscustomobject]@{ mutatesRemote = $false } -} - -Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/DotNet/DotNetArtifactSupport.psm1 b/utils/plugins/DotNet/DotNetArtifactSupport.psm1 deleted file mode 100644 index 205406e..0000000 --- a/utils/plugins/DotNet/DotNetArtifactSupport.psm1 +++ /dev/null @@ -1,63 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - Shared helpers for discovering .NET NuGet package artifacts on disk. -#> - -function Resolve-DotNetPackageArtifacts { - param( - [Parameter(Mandatory = $true)] - [string]$ArtifactsDirectory, - - [Parameter(Mandatory = $true)] - [string]$Version - ) - - if (-not (Test-Path $ArtifactsDirectory -PathType Container)) { - throw "Artifacts directory not found: $ArtifactsDirectory" - } - - $packageFile = $null - $newestNupkgWrite = [datetime]::MinValue - $nupkgCandidates = Get-ChildItem -Path $ArtifactsDirectory -Filter '*.nupkg' - foreach ($candidate in $nupkgCandidates) { - if (($candidate.Name -like "*$Version*.nupkg") -and ($candidate.Name -notlike '*.symbols.nupkg') -and ($candidate.Name -notlike '*.snupkg')) { - if ($candidate.LastWriteTime -gt $newestNupkgWrite) { - $newestNupkgWrite = $candidate.LastWriteTime - $packageFile = $candidate - } - } - } - - if (-not $packageFile) { - throw "Could not locate generated NuGet package for version $Version in: $ArtifactsDirectory" - } - - $releaseArchiveInputs = @($packageFile.FullName) - - $symbolsPackageFile = $null - $newestSnupkgWrite = [datetime]::MinValue - $snupkgCandidates = Get-ChildItem -Path $ArtifactsDirectory -Filter '*.snupkg' - foreach ($candidate in $snupkgCandidates) { - if ($candidate.Name -like "*$Version*.snupkg") { - if ($candidate.LastWriteTime -gt $newestSnupkgWrite) { - $newestSnupkgWrite = $candidate.LastWriteTime - $symbolsPackageFile = $candidate - } - } - } - - if ($symbolsPackageFile) { - $releaseArchiveInputs += $symbolsPackageFile.FullName - } - - return [pscustomobject]@{ - PackageFile = $packageFile - SymbolsPackageFile = $symbolsPackageFile - ReleaseArchiveInputs = $releaseArchiveInputs - } -} - -Export-ModuleMember -Function Resolve-DotNetPackageArtifacts diff --git a/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 b/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 deleted file mode 100644 index bbc7459..0000000 --- a/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 +++ /dev/null @@ -1,122 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - .NET artifact cleanup plugin — remove NuGet build outputs after release. - -.DESCRIPTION - Removes files from the configured artifacts directory using glob patterns. - Defaults target NuGet outputs (*.nupkg, *.snupkg). Typically placed at the - end of the Release stage after DotNetCreateArchive or publish plugins. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Get-CleanupPatternsInternal { - param( - [Parameter(Mandatory = $false)] - $ConfiguredPatterns - ) - - if ($null -eq $ConfiguredPatterns) { - return @('*.nupkg', '*.snupkg') - } - - if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { - return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) - } - - if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { - return @('*.nupkg', '*.snupkg') - } - - return @([string]$ConfiguredPatterns) -} - -function Get-ExcludePatternsInternal { - param( - [Parameter(Mandatory = $false)] - $ConfiguredPatterns - ) - - if ($null -eq $ConfiguredPatterns) { - return @() - } - - if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { - return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) - } - - if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { - return @() - } - - return @([string]$ConfiguredPatterns) -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - - $pluginSettings = $Settings - $sharedSettings = $Settings.context - $artifactsDirectory = $sharedSettings.artifactsDirectory - $patterns = Get-CleanupPatternsInternal -ConfiguredPatterns $pluginSettings.includePatterns - $excludePatterns = Get-ExcludePatternsInternal -ConfiguredPatterns $pluginSettings.excludePatterns - - if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { - throw "DotNetCleanupArtifacts plugin requires an artifacts directory in the shared context." - } - - if (-not (Test-Path $artifactsDirectory -PathType Container)) { - Write-Log -Level "WARN" -Message " Artifacts directory not found: $artifactsDirectory" - return - } - - Write-Log -Level "STEP" -Message "Cleaning generated artifacts..." - - $itemsToRemove = @() - foreach ($pattern in $patterns) { - $matchedItems = @( - Get-ChildItem -Path $artifactsDirectory -Force -ErrorAction SilentlyContinue | - Where-Object { $_.Name -like $pattern } - ) - - if ($excludePatterns.Count -gt 0) { - $matchedItems = @( - $matchedItems | - Where-Object { - $item = $_ - -not ($excludePatterns | Where-Object { $item.Name -like $_ } | Select-Object -First 1) - } - ) - } - - $itemsToRemove += @($matchedItems) - } - - $itemsToRemove = @($itemsToRemove | Sort-Object FullName -Unique) - - if ($itemsToRemove.Count -eq 0) { - Write-Log -Level "INFO" -Message " No artifacts matched cleanup rules." - return - } - - foreach ($item in $itemsToRemove) { - Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue - Write-Log -Level "OK" -Message " Removed: $($item.Name)" - } -} - -Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetCreateArchive.psm1 b/utils/plugins/DotNet/DotNetCreateArchive.psm1 deleted file mode 100644 index e921189..0000000 --- a/utils/plugins/DotNet/DotNetCreateArchive.psm1 +++ /dev/null @@ -1,102 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - .NET release archive plugin — zip from NuGet pack/publish artifacts. - -.DESCRIPTION - This plugin compresses .NET release artifact inputs prepared by an earlier - DotNet plugin (DotNetPack or DotNetPublish) into a zip file - and exposes the resulting release assets for later publisher plugins. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" - - $pluginSettings = $Settings - $sharedSettings = $Settings.context - $artifactsDirectory = $sharedSettings.artifactsDirectory - $version = $sharedSettings.version - $archiveInputs = @() - - $fromFact = Get-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -LegacyProperty @('releaseArchiveInputs') - if ($null -ne $fromFact) { - $archiveInputs = @($fromFact) - } - else { - $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile') - if ($null -ne $packageFile) { - $archiveInputs = @($packageFile.FullName) - $symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile') - if ($null -ne $symbolsPackageFile) { - $archiveInputs += $symbolsPackageFile.FullName - } - } - } - - if ($archiveInputs.Count -eq 0) { - throw "DotNetCreateArchive plugin requires prepared artifacts. Run DotNetPack or DotNetPublish first." - } - - if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { - throw "DotNetCreateArchive plugin requires an artifacts directory in the shared context." - } - - if (-not (Test-Path $artifactsDirectory -PathType Container)) { - New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null - } - - $zipNamePattern = if ($pluginSettings.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.zipNamePattern)) { - [string]$pluginSettings.zipNamePattern - } - else { - "release-{version}.zip" - } - - $zipFileName = $zipNamePattern -replace '\{version\}', $version - $zipPath = Join-Path $artifactsDirectory $zipFileName - - if (Test-Path $zipPath) { - Remove-Item -Path $zipPath -Force - } - - Write-Log -Level "STEP" -Message "Creating release archive..." - Compress-Archive -Path $archiveInputs -DestinationPath $zipPath -CompressionLevel Optimal -Force - - if (-not (Test-Path $zipPath -PathType Leaf)) { - throw "Failed to create release archive at: $zipPath" - } - - Write-Log -Level "OK" -Message " Release archive ready: $zipPath" - - $releaseAssetPaths = @($zipPath) - $packageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -LegacyProperty @('packageFile') - if ($null -ne $packageFile) { - $releaseAssetPaths += $packageFile.FullName - } - $symbolsPackageFile = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -LegacyProperty @('symbolsPackageFile') - if ($null -ne $symbolsPackageFile) { - $releaseAssetPaths += $symbolsPackageFile.FullName - } - - Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory - Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archivePath' -Value $zipPath -Overwrite Replace -LegacyProperty 'releaseArchivePath' - Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $releaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths' -} - -Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetNuGet.psm1 b/utils/plugins/DotNet/DotNetNuGet.psm1 deleted file mode 100644 index ef08a81..0000000 --- a/utils/plugins/DotNet/DotNetNuGet.psm1 +++ /dev/null @@ -1,90 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - .NET NuGet publish plugin. - -.DESCRIPTION - This plugin publishes the package artifact from shared runtime - context to the configured NuGet feed using the configured API key. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" - - $pluginSettings = $Settings - $sharedSettings = $Settings.context - $nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' - $packageFile = $sharedSettings.packageFile - - $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings - - Assert-Command dotnet - - if (-not $packageFile) { - throw "DotNetNuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running DotNetNuGet." - } - - if ($dryRun) { - $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { - "https://api.nuget.org/v3/index.json" - } - else { - $pluginSettings.source - } - - Write-Log -Level "INFO" -Message "Dry run: would push $($packageFile.FullName) to $nugetSource" - return - } - - if ([string]::IsNullOrWhiteSpace($nugetSecret)) { - throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)." - } - - $nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret - if ([string]::IsNullOrWhiteSpace($nugetKey)) { - throw "NuGet API key is not set. Set environment variable '$nugetSecret'." - } - - $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { - "https://api.nuget.org/v3/index.json" - } - else { - $pluginSettings.source - } - - Write-Log -Level "STEP" -Message "Pushing package to NuGet feed..." - dotnet nuget push $packageFile.FullName -k $nugetKey -s $nugetSource --skip-duplicate - - if ($LASTEXITCODE -ne 0) { - throw "Failed to push the package to NuGet feed." - } - - Write-Log -Level "OK" -Message " NuGet push completed." - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion" - Add-EnginePublishCompletion -Context $sharedSettings -Publisher 'DotNetNuGet' -} - -function Get-PluginMetadata { - [pscustomobject]@{ mutatesRemote = $true } -} - -Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata - - - diff --git a/utils/plugins/DotNet/DotNetPack.psm1 b/utils/plugins/DotNet/DotNetPack.psm1 deleted file mode 100644 index 5929045..0000000 --- a/utils/plugins/DotNet/DotNetPack.psm1 +++ /dev/null @@ -1,98 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - .NET pack plugin for producing package artifacts. - -.DESCRIPTION - This plugin creates package output for the release pipeline. - It packs the configured .NET project, resolves the generated - package artifacts, and publishes them into shared runtime context - for later plugins. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - # Load this globally only as a fallback. Re-importing PluginSupport in its own execution path - # can invalidate commands already resolved by the release engine. - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" - Import-PluginDependency -ModuleName "DotNetArtifactSupport" -RequiredCommand "Resolve-DotNetPackageArtifacts" - - $sharedSettings = $Settings.context - $scriptDir = $sharedSettings.scriptDir - $version = $sharedSettings.version - - if ($Settings.PSObject.Properties['projectFiles'] -and $null -ne $Settings.projectFiles) { - $projectFiles = @(Resolve-RelativePaths -Value $Settings.projectFiles -BasePath $scriptDir) - } - elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) { - $projectFiles = @($sharedSettings.projectFiles) - } - else { - $projectFiles = @() - } - - if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) { - $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir))) - Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory - Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory - } - else { - $artifactsDirectory = $sharedSettings.artifactsDirectory - } - - Assert-Command dotnet - - if ($projectFiles.Count -eq 0) { - throw "DotNetPack plugin requires projectFiles in plugin settings or projectFiles on shared context." - } - - $outputDir = $artifactsDirectory - - if (!(Test-Path $outputDir)) { - New-Item -ItemType Directory -Path $outputDir | Out-Null - } - - # First path in the configured project list is the pack target. - $packageProjectPath = (@($projectFiles))[0] - Write-Log -Level "STEP" -Message "Packing NuGet package..." - $dotnetPackArguments = @( - 'pack', $packageProjectPath, '-c', 'Release', '-o', $outputDir, '--nologo', - '-p:IncludeSymbols=true', '-p:SymbolPackageFormat=snupkg' - ) - & dotnet @dotnetPackArguments - if ($LASTEXITCODE -ne 0) { - throw "dotnet pack failed for $packageProjectPath." - } - - $resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $outputDir -Version $version - - Write-Log -Level "OK" -Message " Package ready: $($resolved.PackageFile.FullName)" - if ($resolved.SymbolsPackageFile) { - Write-Log -Level "OK" -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)" - } - else { - Write-Log -Level "WARN" -Message " Symbols package (.snupkg) not found for version $version." - } - - Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile' - Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile' - Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' -} - -Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetPublish.psm1 b/utils/plugins/DotNet/DotNetPublish.psm1 deleted file mode 100644 index d2dacba..0000000 --- a/utils/plugins/DotNet/DotNetPublish.psm1 +++ /dev/null @@ -1,75 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - .NET publish plugin for producing application release artifacts. - -.DESCRIPTION - This plugin publishes the configured .NET project into a release output - directory and exposes that published directory to the shared release - context so later release-stage plugins can archive and publish it. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact" - - $sharedSettings = $Settings.context - $projectFiles = Get-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'projectFiles' -LegacyProperty @('projectFiles') - $artifactsDirectory = $sharedSettings.artifactsDirectory - $publishProjectPath = $null - - Assert-Command dotnet - - if ($null -eq $projectFiles -or @($projectFiles).Count -eq 0) { - throw "DotNetPublish plugin requires project files in the shared context." - } - - $projectFiles = @($projectFiles) - - if (!(Test-Path $artifactsDirectory)) { - New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null - } - - # The first configured project remains the canonical release artifact source. - $publishProjectPath = $projectFiles[0] - $publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath)) - - if (Test-Path $publishDir) { - Remove-Item -Path $publishDir -Recurse -Force - } - - Write-Log -Level "STEP" -Message "Publishing release artifact..." - dotnet publish $publishProjectPath -c Release -o $publishDir --nologo - if ($LASTEXITCODE -ne 0) { - throw "dotnet publish failed for $publishProjectPath." - } - - $publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue) - if ($publishedItems.Count -eq 0) { - throw "dotnet publish completed, but no files were produced in: $publishDir" - } - - Write-Log -Level "OK" -Message " Published artifact ready: $publishDir" - - Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $null -Overwrite Replace -LegacyProperty 'packageFile' - Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $null -Overwrite Replace -LegacyProperty 'symbolsPackageFile' - Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value @($publishDir) -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' -} - -Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 deleted file mode 100644 index 3cb01fd..0000000 --- a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 +++ /dev/null @@ -1,96 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - Loads release version from an SDK-style .csproj into shared context. - -.DESCRIPTION - Dedicated version-loading plugin. Reads from the first configured - projectFiles entry and writes it (plus the resolved projectFiles) to the - shared runtime context. Declares providesVersion = $true so the engine can - discover it as the single release version source. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Get-CsprojPropertyValueInternal { - param( - [Parameter(Mandatory = $true)] - [xml]$Csproj, - - [Parameter(Mandatory = $true)] - [string]$PropertyName - ) - - # SDK-style .csproj files can have multiple PropertyGroup nodes. - # Use the first group that defines the requested property. - $propNode = $Csproj.Project.PropertyGroup | - Where-Object { $_.$PropertyName } | - Select-Object -First 1 - - if ($propNode) { - return $propNode.$PropertyName - } - - return $null -} - -function Get-CsprojVersionInternal { - param( - [Parameter(Mandatory = $true)] - [string]$ProjectPath - ) - - if (-not (Test-Path $ProjectPath -PathType Leaf)) { - throw "DotNetReleaseVersion: project file not found at '$ProjectPath'." - } - - if ([System.IO.Path]::GetExtension($ProjectPath) -ne ".csproj") { - throw "DotNetReleaseVersion: configured project file is not a .csproj file: '$ProjectPath'." - } - - [xml]$csproj = Get-Content $ProjectPath - $version = Get-CsprojPropertyValueInternal -Csproj $csproj -PropertyName "Version" - - if ([string]::IsNullOrWhiteSpace([string]$version)) { - throw "DotNetReleaseVersion: not found in '$ProjectPath'." - } - - return [string]$version -} - -function Get-PluginMetadata { - return [pscustomobject]@{ providesVersion = $true } -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" - - $shared = $Settings.context - $projectFiles = @(Resolve-RelativePaths -Value $Settings.projectFiles -BasePath $shared.scriptDir) - if ($projectFiles.Count -eq 0) { - throw "DotNetReleaseVersion plugin requires 'projectFiles' (first .csproj with ) in scriptSettings.json." - } - - Write-Log -Level "INFO" -Message "Reading version from SDK-style project file (projectFiles)..." - $version = Get-CsprojVersionInternal -ProjectPath $projectFiles[0] - - Set-EngineState -Context $shared -Name 'version' -Value $version - Set-EngineFact -Context $shared -Namespace 'dotnet' -Name 'projectFiles' -Value $projectFiles -Overwrite Replace -LegacyProperty 'projectFiles' - Write-Log -Level "OK" -Message " Release version loaded by DotNetReleaseVersion plugin: $version" -} - -Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/DotNet/DotNetTest.psm1 b/utils/plugins/DotNet/DotNetTest.psm1 deleted file mode 100644 index b8bd09d..0000000 --- a/utils/plugins/DotNet/DotNetTest.psm1 +++ /dev/null @@ -1,90 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - .NET test plugin for executing automated tests. - -.DESCRIPTION - Resolves one or more .NET test projects (`project` or `projects`), runs tests once - via TestRunner, then publishes metrics on the shared engine context for any later - plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`, - method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read - those keys generically (not tied to this plugin by name). When `resultsDir` (or the - multi-project default TestResults folder) is used, Cobertura output is kept on disk - via TestRunner `-KeepResults` so repo-root `test-results/` persists after the run. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - # Same fallback pattern as the other plugins: use the existing shared module if it is already loaded. - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Invoke-TestsWithCoverage" - - $pluginSettings = $Settings - $sharedSettings = $Settings.context - $testResultsDirSetting = $pluginSettings.resultsDir - $scriptDir = $sharedSettings.scriptDir - - $testProjectPaths = [System.Collections.Generic.List[string]]::new() - if ($pluginSettings.PSObject.Properties.Name -contains 'projects' -and $pluginSettings.projects) { - foreach ($rel in @($pluginSettings.projects)) { - if ([string]::IsNullOrWhiteSpace([string]$rel)) { continue } - $testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $rel.Trim()))) - } - } - if ($testProjectPaths.Count -eq 0 -and $pluginSettings.project) { - $testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $pluginSettings.project))) - } - if ($testProjectPaths.Count -eq 0) { - throw "DotNetTest plugin requires 'project' or 'projects' in scriptSettings.json." - } - - $testResultsDir = $null - if (-not [string]::IsNullOrWhiteSpace($testResultsDirSetting)) { - $testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testResultsDirSetting)) - } - elseif ($testProjectPaths.Count -gt 1) { - $testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir "TestResults")) - } - - Write-Log -Level "STEP" -Message "Running tests..." - - # Build a splatted hashtable so optional arguments can be added without duplicating the call site. - $invokeTestParams = @{ - TestProjectPath = @($testProjectPaths) - Silent = $true - } - if ($testResultsDir) { - $invokeTestParams.ResultsDirectory = $testResultsDir - $invokeTestParams.KeepResults = $true - } - - $testResult = Invoke-TestsWithCoverage @invokeTestParams - - if (-not $testResult.Success) { - throw "Tests failed. $($testResult.Error)" - } - - Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Publish-CoverageMetricsToSharedContext" - Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult - - Write-Log -Level "OK" -Message " All tests passed!" - Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%" - Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%" - Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%" -} - -Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1 index db6b0ec..c7f5713 100644 --- a/utils/plugins/Npm/NpmPublish.psm1 +++ b/utils/plugins/Npm/NpmPublish.psm1 @@ -6,9 +6,9 @@ Publishes npm workspace packages to the npm registry. .DESCRIPTION - Publishes packages in configured order using npmSecret (logical secret name). - Pass the token via an environment variable named like the configured npm secret (e.g. Npm). - Uses a temporary .npmrc in the workspace root. + Stages packages in configured order using RepoUtilsSecrets slot Npm. + Uses npm stage publish so a maintainer approves the version with 2FA. + Requires npm CLI 11.15.0 or newer. Uses a temporary .npmrc in the workspace root. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -28,6 +28,7 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" + Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ReleaseSemverPrereleaseLabel" $pluginSettings = $Settings $shared = $Settings.context @@ -62,6 +63,14 @@ function Invoke-Plugin { [string]$pluginSettings.access } + $npmDistTag = $null + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.npmDistTag)) { + $npmDistTag = [string]$pluginSettings.npmDistTag + } + else { + $npmDistTag = Get-ReleaseSemverPrereleaseLabel -Version ([string]$shared.version) + } + $publishOrder = @() if ($pluginSettings.publishOrder) { if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) { @@ -76,6 +85,8 @@ function Invoke-Plugin { throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)." } + $npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' -PluginDisplayName 'NpmPublish' -Required + Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force $useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot if (-not $useWorkspaces -and $publishOrder.Count -gt 1) { @@ -84,19 +95,24 @@ function Invoke-Plugin { if ($dryRun) { foreach ($packageName in $publishOrder) { - Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry" + $tagNote = if ([string]::IsNullOrWhiteSpace($npmDistTag)) { 'latest' } else { $npmDistTag } + Write-Log -Level "INFO" -Message "Dry run: would stage npm package '$packageName' to $registry (dist-tag $tagNote). Approval with 2FA is separate." } return } - $npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' - if ([string]::IsNullOrWhiteSpace($npmSecret)) { - throw "NpmPublish plugin requires 'npmSecret' in scriptSettings.json (logical secret name, e.g. Npm)." + $npmCliVersion = [string](& npm --version) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($npmCliVersion)) { + throw "NpmPublish could not read the npm CLI version." + } + $npmCliVersion = $npmCliVersion.Trim() + if (([version]$npmCliVersion) -lt [version]'11.15.0') { + throw "NpmPublish requires npm CLI 11.15.0 or newer for 'npm stage publish' (installed: $npmCliVersion). Direct publish cannot defer 2FA." } - $npmToken = Get-SecretEnvironmentValue -Name $npmSecret + $npmToken = Get-RepoUtilsSecretSlot -Name $npmSecret -Settings $shared if ([string]::IsNullOrWhiteSpace($npmToken)) { - throw "npm API key is not set. Set environment variable '$npmSecret'." + throw "npm API key is not set. Set RepoUtilsSecrets slot '$npmSecret' (npmSecret)." } $registryHost = ([uri]$registry).Host @@ -111,22 +127,29 @@ registry=$registry Set-Content -Path $tempNpmRcPath -Value $npmRcContent -Encoding UTF8 -NoNewline foreach ($packageName in $publishOrder) { - Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..." + Write-Log -Level "STEP" -Message "Staging npm package '$packageName' (approve later with 2FA)..." + $publishArgs = @('stage', 'publish') if ($useWorkspaces) { - npm publish -w $packageName --access $access --userconfig $tempNpmRcPath + $publishArgs += @('-w', $packageName) } else { Assert-NpmRootPackageName -WorkspaceRoot $workspaceRoot -ExpectedPackageName $packageName - npm publish --access $access --userconfig $tempNpmRcPath } + $publishArgs += @('--access', $access, '--userconfig', $tempNpmRcPath) + if (-not [string]::IsNullOrWhiteSpace($npmDistTag)) { + $publishArgs += @('--tag', $npmDistTag) + Write-Log -Level "INFO" -Message " Using npm dist-tag '$npmDistTag' (prerelease)." + } + + npm @publishArgs if ($LASTEXITCODE -ne 0) { - throw "Failed to publish npm package '$packageName'." + throw "Failed to stage npm package '$packageName'." } - Write-Log -Level "OK" -Message " Published $packageName." + Write-Log -Level "OK" -Message " Staged $packageName. It is not installable until a maintainer runs 'npm stage approve' with 2FA." } - Write-Log -Level "OK" -Message " npm publish completed." + Write-Log -Level "OK" -Message " npm stage publish completed." Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-EnginePublishCompletion" Add-EnginePublishCompletion -Context $shared -Publisher 'NpmPublish' } diff --git a/utils/plugins/Npm/NpmReleaseVersion.psm1 b/utils/plugins/Npm/NpmReleaseVersion.psm1 index ad48e46..31f300a 100644 --- a/utils/plugins/Npm/NpmReleaseVersion.psm1 +++ b/utils/plugins/Npm/NpmReleaseVersion.psm1 @@ -35,8 +35,9 @@ function Get-PackageJsonVersionInternal { throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'." } - if ($version -notmatch '^\d+\.\d+\.\d+') { - throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver." + Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver" + if (-not (Test-ReleaseSemver -Version $version)) { + throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)." } return $version @@ -69,6 +70,7 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" + Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver" $pluginSettings = $Settings $shared = $Settings.context diff --git a/utils/plugins/Platform/CollectCoverage.psm1 b/utils/plugins/Platform/CollectCoverage.psm1 new file mode 100644 index 0000000..a7d959c --- /dev/null +++ b/utils/plugins/Platform/CollectCoverage.psm1 @@ -0,0 +1,78 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Collects coverage metrics from an existing test results directory. + +.DESCRIPTION + Stack-agnostic parser for recovered container or host test output. Auto-detects .NET + Cobertura (coverage.cobertura.xml) or Jest (coverage-summary.json) and publishes the + same shared-context keys as DotNetTest / NpmJestTest for QualityGate. + Use after DockerContainerBuilder / PodmanContainerBuilder when tests run inside a container image. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Get-CoverageFromResultsDirectory' + Import-PluginDependency -ModuleName 'TestRunner' -RequiredCommand 'Publish-CoverageMetricsToSharedContext' + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $scriptDir = $sharedSettings.scriptDir + + $resultsDirSetting = $pluginSettings.resultsDir + if ([string]::IsNullOrWhiteSpace($resultsDirSetting)) { + if ($sharedSettings.PSObject.Properties.Name -contains 'testResultsDirectory' -and $sharedSettings.testResultsDirectory) { + $resultsDirSetting = $sharedSettings.testResultsDirectory + } + else { + throw "CollectCoverage requires 'resultsDir' in plugin settings or testResultsDirectory on shared context (from ContainerBuilder)." + } + } + + $resultsDirectory = if ([System.IO.Path]::IsPathRooted([string]$resultsDirSetting)) { + [string]$resultsDirSetting + } + else { + [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$resultsDirSetting))) + } + + $format = 'auto' + if ($pluginSettings.PSObject.Properties.Name -contains 'format' -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.format)) { + $format = [string]$pluginSettings.format + } + + Write-Log -Level 'STEP' -Message "Collecting coverage from $resultsDirectory ..." + $testResult = Get-CoverageFromResultsDirectory -ResultsDirectory $resultsDirectory -Format $format -Silent:$true + if (-not $testResult.Success) { + throw $testResult.Error + } + + Publish-CoverageMetricsToSharedContext -SharedSettings $sharedSettings -TestResult $testResult + + $formatLabel = if ($testResult.CoverageFormat) { $testResult.CoverageFormat } else { 'unknown' } + Write-Log -Level 'OK' -Message " Coverage collected ($formatLabel)." + Write-Log -Level 'INFO' -Message " Line Coverage: $($testResult.LineRate)%" + Write-Log -Level 'INFO' -Message " Branch Coverage: $($testResult.BranchRate)%" + Write-Log -Level 'INFO' -Message " Method Coverage: $($testResult.MethodRate)%" +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $false } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Platform/ContainerEngineProbe.psm1 b/utils/plugins/Platform/ContainerEngineProbe.psm1 new file mode 100644 index 0000000..896501e --- /dev/null +++ b/utils/plugins/Platform/ContainerEngineProbe.psm1 @@ -0,0 +1,52 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Probes docker/podman availability and sets mandatory shared engine state for the pipeline. + +.DESCRIPTION + Must be the first enabled plugin. Sets containerEngineProbeCompleted, activeContainerEngine, + dockerEngineAvailable, podmanEngineAvailable, and compose CLI fields on shared context. + Fails the pipeline when no usable engine is found. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'ContainerEngineSupport' -RequiredCommand 'Resolve-ActiveContainerEngineState' + + $shared = $Settings.context + + Write-Log -Level 'STEP' -Message 'Probing container engine availability...' + $state = Resolve-ActiveContainerEngineState + Set-ContainerEngineSharedState -SharedSettings $shared -State $state + + $display = Get-ContainerEngineDisplayName -Engine (Get-SharedContainerEngineInvocation -SharedSettings $shared) + Write-Log -Level 'OK' -Message " Active engine: $display" + Write-Log -Level 'INFO' -Message " docker CLI available: $($state.DockerEngineAvailable); podman CLI available: $($state.PodmanEngineAvailable)" + if ([string]::IsNullOrWhiteSpace([string]$state.ComposeExecutable)) { + Write-Log -Level 'INFO' -Message ' Compose CLI: not available (Compose plugins will fail if enabled).' + } + else { + Write-Log -Level 'INFO' -Message " Compose CLI: $($state.ComposeExecutable) $($state.ComposeBaseArgs -join ' ')" + } +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $false } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Platform/DiscoverPackageArtifacts.psm1 b/utils/plugins/Platform/DiscoverPackageArtifacts.psm1 new file mode 100644 index 0000000..15ec38f --- /dev/null +++ b/utils/plugins/Platform/DiscoverPackageArtifacts.psm1 @@ -0,0 +1,124 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Discovers package artifacts produced by pack plugins or container build pipelines. + +.DESCRIPTION + Stack-agnostic scanner for artifactsDir. Detects .NET .nupkg/.snupkg or npm .tgz files + and populates shared context (packageFile, releaseAssetPaths, releaseArchiveInputs). +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'DotNetArtifactSupport' -RequiredCommand 'Resolve-DotNetPackageArtifacts' + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $scriptDir = $sharedSettings.scriptDir + $version = $sharedSettings.version + + if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) { + $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir))) + if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) { + Set-EngineState -Context $sharedSettings -Name 'artifactsDirectory' -Value $artifactsDirectory + Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory + } + else { + $sharedSettings | Add-Member -NotePropertyName artifactsDirectory -NotePropertyValue $artifactsDirectory -Force + $sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force + } + } + else { + $artifactsDirectory = $sharedSettings.artifactsDirectory + } + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw 'DiscoverPackageArtifacts requires artifactsDir in plugin settings or artifactsDirectory on shared context.' + } + + $nupkgCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.nupkg' -ErrorAction SilentlyContinue | Where-Object { + $_.Name -notlike '*.symbols.nupkg' -and $_.Name -notlike '*.snupkg' + }) + $tgzCandidates = @(Get-ChildItem -Path $artifactsDirectory -Filter '*.tgz' -ErrorAction SilentlyContinue) + + if ($nupkgCandidates.Count -gt 0 -and $tgzCandidates.Count -gt 0) { + throw "DiscoverPackageArtifacts found both NuGet and npm artifacts in $artifactsDirectory. Use one package stack per repository." + } + + if ($nupkgCandidates.Count -gt 0) { + if ([string]::IsNullOrWhiteSpace([string]$version)) { + throw 'DiscoverPackageArtifacts requires release version on shared context when discovering .nupkg artifacts.' + } + + Write-Log -Level 'STEP' -Message "Discovering NuGet package artifacts in $artifactsDirectory ..." + $resolved = Resolve-DotNetPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version + + Write-Log -Level 'OK' -Message " Package ready: $($resolved.PackageFile.FullName)" + if ($resolved.SymbolsPackageFile) { + Write-Log -Level 'OK' -Message " Symbols package ready: $($resolved.SymbolsPackageFile.FullName)" + } + else { + Write-Log -Level 'WARN' -Message " Symbols package (.snupkg) not found for version $version." + } + + $sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force + $sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $resolved.SymbolsPackageFile -Force + $sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force + $sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseArchiveInputs -Force + if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) { + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'dotnet' -Name 'symbolsPackageFile' -Value $resolved.SymbolsPackageFile -Overwrite Replace -LegacyProperty 'symbolsPackageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseAssetPaths' + } + return + } + + if ($tgzCandidates.Count -gt 0) { + Import-PluginDependency -ModuleName 'NpmArtifactSupport' -RequiredCommand 'Resolve-NpmPackageArtifacts' + + Write-Log -Level 'STEP' -Message "Discovering npm package artifacts in $artifactsDirectory ..." + $resolved = Resolve-NpmPackageArtifacts -ArtifactsDirectory $artifactsDirectory -Version $version + + foreach ($tarball in $resolved.PackageFiles) { + Write-Log -Level 'OK' -Message " Package ready: $($tarball.FullName)" + } + + $sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $resolved.PackageFile -Force + $sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $resolved.ReleaseArchiveInputs -Force + $sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $resolved.ReleaseAssetPaths -Force + $sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force + if (Get-Command Set-EngineFact -ErrorAction SilentlyContinue) { + Set-EngineFact -Context $sharedSettings -Namespace 'npm' -Name 'packageFile' -Value $resolved.PackageFile -Overwrite Replace -LegacyProperty 'packageFile' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'archiveInputs' -Value $resolved.ReleaseArchiveInputs -Overwrite Replace -LegacyProperty 'releaseArchiveInputs' + Set-EngineFact -Context $sharedSettings -Namespace 'release' -Name 'assetPaths' -Value $resolved.ReleaseAssetPaths -Overwrite Replace -LegacyProperty 'releaseAssetPaths' + if (Get-Command Set-EngineState -ErrorAction SilentlyContinue) { + Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory + } + } + return + } + + throw "DiscoverPackageArtifacts found no .nupkg or .tgz files in: $artifactsDirectory" +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $false } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Platform/FileReleaseVersion.psm1 b/utils/plugins/Platform/FileReleaseVersion.psm1 index c7bb3bd..6095289 100644 --- a/utils/plugins/Platform/FileReleaseVersion.psm1 +++ b/utils/plugins/Platform/FileReleaseVersion.psm1 @@ -7,9 +7,10 @@ .DESCRIPTION Reads a single-line semver from the configured versionFilePath (default - repo-root VERSION). Useful for repositories without .csproj or package.json - version metadata. Declares providesVersion = $true so the engine can - discover it as the single release version source. + repo-root VERSION), including optional prerelease (0.1.0-alpha.1). Useful for + repositories without .csproj or package.json version metadata. Declares + providesVersion = $true so the engine can discover it as the single release + version source. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -36,8 +37,9 @@ function Get-VersionFileSemverInternal { } $version = $version -replace '^[vV]', '' - if ($version -notmatch '^\d+\.\d+\.\d+') { - throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver." + Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver" + if (-not (Test-ReleaseSemver -Version $version)) { + throw "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver (X.Y.Z or X.Y.Z-prerelease)." } return $version @@ -55,6 +57,7 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineState" + Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemver" $shared = $Settings.context $versionFileSetting = if ($Settings.versionFilePath) { diff --git a/utils/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 index 61773e0..89be3fc 100644 --- a/utils/plugins/Platform/GitHub.psm1 +++ b/utils/plugins/Platform/GitHub.psm1 @@ -10,7 +10,8 @@ repository, and creates the configured GitHub release using the shared release artifacts and release notes from CHANGELOG.md. Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD - (see ChangelogSupport.psm1). + (including optional SemVer prerelease, e.g. ## [0.1.0-alpha.1] / [0.1.0-beta.1] / [0.1.0-rc.1]; + see ChangelogSupport.psm1). Hyphenated versions are created with gh --prerelease. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -95,11 +96,11 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion" + Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Test-ReleaseSemverPrerelease" Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Get-EngineFact" $pluginSettings = $Settings $sharedSettings = $Settings.context - $githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' $configuredRepository = $pluginSettings.repository $releaseNotesFileSetting = $pluginSettings.releaseNotesFile $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern @@ -110,6 +111,7 @@ function Invoke-Plugin { $releaseAssetPaths = @() $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings + $githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' -PluginDisplayName 'GitHub' -Required if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json." @@ -128,18 +130,17 @@ function Invoke-Plugin { } $releaseName = $releaseTitlePattern -replace '\{version\}', $version Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo" + if (Test-ReleaseSemverPrerelease -Version ([string]$version)) { + Write-Log -Level "INFO" -Message "Dry run: release would be marked prerelease." + } return } Assert-Command gh - if ([string]::IsNullOrWhiteSpace($githubSecret)) { - throw "GitHub plugin requires 'githubSecret' in scriptSettings.json (logical secret name, e.g. GitHub)." - } - - $ghToken = Get-SecretEnvironmentValue -Name $githubSecret + $ghToken = Get-RepoUtilsSecretSlot -Name $githubSecret -Settings $sharedSettings if ([string]::IsNullOrWhiteSpace($ghToken)) { - throw "GitHub token is not set. Set environment variable '$githubSecret'." + throw "GitHub token is not set. Set RepoUtilsSecrets slot '$githubSecret' (githubSecret)." } if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { @@ -230,7 +231,7 @@ function Invoke-Plugin { $authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } } - throw "GitHub CLI authentication failed for repository '$repo'. Ensure secret '$githubSecret' is valid and has access to this repository." + throw "GitHub CLI authentication failed for repository '$repo'. Ensure RepoUtilsSecrets slot '$githubSecret' is valid and has access to this repository." } Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)" @@ -261,6 +262,10 @@ function Invoke-Plugin { "--title", $releaseName, "--notes-file", $notesFilePath ) + if (Test-ReleaseSemverPrerelease -Version ([string]$version)) { + $createReleaseArgs += '--prerelease' + } + & gh @createReleaseArgs if ($LASTEXITCODE -ne 0) { diff --git a/utils/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1 index 43b6dfe..56153f6 100644 --- a/utils/plugins/Platform/ReleasePublishGuard.psm1 +++ b/utils/plugins/Platform/ReleasePublishGuard.psm1 @@ -11,8 +11,9 @@ when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin branch lists; put allowed branches here instead. - Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD, - tag version vs DotNetReleaseVersion, optional push tag to remote. + Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD + (vX.Y.Z or vX.Y.Z-prerelease such as v0.1.0-alpha.1 / v0.1.0-beta.1 / v0.1.0-rc.1), + tag version vs release version, optional push tag to remote. The engine preflight no longer reads git tags; this plugin sets context.tag from the git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion. @@ -77,6 +78,7 @@ function Invoke-Plugin { Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort" Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists" Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote" + Import-PluginDependency -ModuleName "ChangelogSupport" -RequiredCommand "Get-ChangelogSemverPattern" $pluginSettings = $Settings $shared = $Settings.context @@ -123,8 +125,9 @@ function Invoke-Plugin { return } - if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { - Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z." + $tagPattern = '^v(' + (Get-ChangelogSemverPattern) + ')$' + if ($tag -notmatch $tagPattern) { + Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z or vX.Y.Z-prerelease (e.g. v0.1.0-alpha.1, v0.1.0-beta.1, v0.1.0-rc.1)." return } diff --git a/utils/templates/Dockerfile.ci.dotnet b/utils/templates/Dockerfile.ci.dotnet deleted file mode 100644 index 78bb6a1..0000000 --- a/utils/templates/Dockerfile.ci.dotnet +++ /dev/null @@ -1,21 +0,0 @@ -# CI builder image for .NET NuGet libraries: test (Coverlet) + pack in one ENTRYPOINT. -# -# Setup in a product repo: -# 1. Copy to utils/engines/containerbuilder/{repo}-containerbuilder.Dockerfile -# 2. Customize test/pack .csproj paths below -# 3. Reference the same dockerfile from engines/test and engines/release scriptSettings -# 4. Disable host DotNetTest and DotNetPack; enable CollectCoverage + DiscoverPackageArtifacts (release) -# -# Source injection: ContainerBuilder copies sourceContextPath into WORKDIR (/src). -# - Repo root (sourceContextPath ..\..\..): use paths like src/YourProject/YourProject.csproj -# - src folder (sourceContextPath ..\..\..\src): use paths like YourProject/YourProject.csproj -# -# Reference: maksit-nats (utils/engines/containerbuilder/maksit-nats-containerbuilder.Dockerfile) - -FROM mcr.microsoft.com/dotnet/sdk:10.0 -WORKDIR /src -ENV ARTIFACTS_DIR=/artifacts -RUN mkdir -p /artifacts /testResults - -# Option B: COPY build/ci-dotnet-pack.sh /usr/local/bin/ci-pack.sh && chmod +x ... && ENTRYPOINT ["/usr/local/bin/ci-pack.sh"] -ENTRYPOINT ["sh", "-c", "dotnet test path/to/YourProject.Tests/YourProject.Tests.csproj -c Release --nologo --collect:\"XPlat Code Coverage\" --results-directory /testResults && dotnet pack path/to/YourProject/YourProject.csproj -c Release -o /artifacts --nologo -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg"] diff --git a/utils/templates/Dockerfile.ci.npm b/utils/templates/Dockerfile.ci.npm deleted file mode 100644 index c5525aa..0000000 --- a/utils/templates/Dockerfile.ci.npm +++ /dev/null @@ -1,22 +0,0 @@ -# CI builder image for npm libraries: ci, Jest coverage, build, and pack (.tgz). -# -# Setup in a product repo: -# 1. Copy to utils/engines/containerbuilder/{repo}-containerbuilder.Dockerfile -# 2. Customize WORKDIR (package root), testScript, and build script names -# 3. Reference the same dockerfile from engines/test and engines/release scriptSettings -# 4. Disable host NpmJestTest / NpmBuild / NpmPack when the container owns the pipeline -# -# Source injection: set sourceContextPath to the folder that contains package.json (often repo src/). -# Recovered paths: /artifacts (.tgz), /testResults (coverage/ for Jest json-summary). -# -# CollectCoverage reads recovered /testResults for QualityGate; DiscoverPackageArtifacts finds .tgz in /artifacts. - -FROM node:24-bookworm-slim -WORKDIR /src -ENV ARTIFACTS_DIR=/artifacts -ENV NPM_CONFIG_FUND=false -ENV NPM_CONFIG_AUDIT=false -RUN mkdir -p /artifacts /testResults - -# Option B: COPY build/ci-npm-pack.sh /usr/local/bin/ci-npm.sh && chmod +x ... && ENTRYPOINT ["/usr/local/bin/ci-npm.sh"] -ENTRYPOINT ["sh", "-c", "npm ci && npm run test:coverage -- --coverage --coverageReporters=json-summary --coverageReporters=text && cp -r coverage /testResults/ && npm run build && npm pack --pack-destination /artifacts"] diff --git a/utils/templates/build/ci-dotnet-pack.sh b/utils/templates/build/ci-dotnet-pack.sh deleted file mode 100644 index d5f6934..0000000 --- a/utils/templates/build/ci-dotnet-pack.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -set -eu - -: "${ARTIFACTS_DIR:=/artifacts}" -mkdir -p "$ARTIFACTS_DIR" /testResults - -# Customize test/pack .csproj paths (see templates/Dockerfile.ci.dotnet). -dotnet test path/to/YourProject.Tests/YourProject.Tests.csproj -c Release --nologo \ - --collect:"XPlat Code Coverage" --results-directory /testResults -dotnet pack path/to/YourProject/YourProject.csproj -c Release -o "$ARTIFACTS_DIR" --nologo \ - -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg diff --git a/utils/templates/build/ci-npm-pack.sh b/utils/templates/build/ci-npm-pack.sh deleted file mode 100644 index c0bccf7..0000000 --- a/utils/templates/build/ci-npm-pack.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -set -eu - -: "${ARTIFACTS_DIR:=/artifacts}" -mkdir -p "$ARTIFACTS_DIR" /testResults - -# Customize testScript / build script names for your package.json. -npm ci -npm run test:coverage -- --coverage --coverageReporters=json-summary --coverageReporters=text -cp -r coverage /testResults/ -npm run build -npm pack --pack-destination "$ARTIFACTS_DIR"