diff --git a/Dockerfile b/Dockerfile index 6d08cc4e..d2a78d78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -78,6 +78,13 @@ RUN curl -fsSL https://storage.googleapis.com/flutter_infra_release/releases/sta && tar xf /opt/flutter.tar.xz -C /opt \ && rm /opt/flutter.tar.xz +# https://learn.microsoft.com/en-us/powershell/scripting/install/install-debian +RUN curl -fsSL https://github.com/PowerShell/PowerShell/releases/download/v7.4.1/powershell_7.4.1-1.deb_amd64.deb -o /opt/powershell.deb \ + && dpkg -i /opt/powershell.deb \ + && apt-get install -f \ + && apt-get clean \ + && rm /opt/powershell.deb + # craft does `git` things against mounted directories as root RUN git config --global --add safe.directory '*' diff --git a/README.md b/README.md index 56e777d2..795ddab7 100644 --- a/README.md +++ b/README.md @@ -705,10 +705,10 @@ By default, `craft` publishes all packages with `.nupkg` extension. The `dotnet` tool must be available on the system. -| Name | Description | -| ------------------ | ---------------------------------------------------------------- | -| `NUGET_API_TOKEN` | NuGet personal API token (https://www.nuget.org/account/apikeys) | -| `NUGET_DOTNET_BIN` | **optional**. Path to .NET Core. Defaults to `dotnet` | +| Name | Description | +| ------------------ | ----------------------------------------------------------------- | +| `NUGET_API_TOKEN` | NuGet personal [API token](https://www.nuget.org/account/apikeys) | +| `NUGET_DOTNET_BIN` | **optional**. Path to .NET Core. Defaults to `dotnet` | **Configuration** @@ -1227,6 +1227,38 @@ targets: createTag: true ``` +### PowerShellGet (`powershell`) + +Uploads a module to [PowerShell Gallery](https://www.powershellgallery.com/) or another repository +supported by [PowerShellGet](https://learn.microsoft.com/en-us/powershell/module/powershellget)'s `Publish-Module`. + +The action looks for an artifact named `.zip` and extracts it to a temporary directory. +The extracted directory is then published as a module. + +#### Environment + +The `pwsh` executable [must be installed](https://github.com/powershell/powershell#get-powershell) on the system. + +| Name | Description | Default | +| -------------------- | ---------------------------------------------------- | --------- | +| `POWERSHELL_API_KEY` | **required** PowerShell Gallery API key | | +| `POWERSHELL_BIN` | **optional** Path to PowerShell binary | `pwsh` | + +#### Configuration + +| Option | Description | Default | +| -------------------- | ---------------------------------------------------- | --------- | +| `module` | **required** Module name. | | +| `repository` | **optional** Repository to publish the package to. | PSGallery | + +#### Example + +```yaml +targets: + - name: powershell + module: Sentry +``` + ## Integrating Your Project with `craft` Here is how you can integrate your GitHub project with `craft`: diff --git a/src/targets/__tests__/powershell.test.ts b/src/targets/__tests__/powershell.test.ts new file mode 100644 index 00000000..4ffa4676 --- /dev/null +++ b/src/targets/__tests__/powershell.test.ts @@ -0,0 +1,159 @@ +import { spawnProcess } from '../../utils/system'; +import { NoneArtifactProvider } from '../../artifact_providers/none'; +import { ConfigurationError } from '../../utils/errors'; +import { PowerShellTarget } from '../powershell'; + +jest.mock('fs'); +jest.mock('../../utils/system'); + +/** Returns a new PowerShellTarget test instance. */ +function getPwshTarget(): PowerShellTarget { + return new PowerShellTarget( + { + name: 'powershell', + module: 'moduleName', + repository: 'repositoryName', + }, + new NoneArtifactProvider() + ); +} + +function setPwshEnvironmentVariables() { + process.env.POWERSHELL_API_KEY = 'test access key'; +} + +describe('pwsh environment variables', () => { + const oldEnvVariables = process.env; + + beforeEach(() => { + jest.resetModules(); // Clear the cache. + process.env = { ...oldEnvVariables }; // Restore environment + }); + + afterAll(() => { + process.env = { ...oldEnvVariables }; // Restore environment + }); + + function deleteTargetOptionsFromEnvironment() { + if ('POWERSHELL_API_KEY' in process.env) { + delete process.env.POWERSHELL_API_KEY; + } + } + + test('errors on missing environment variables', () => { + deleteTargetOptionsFromEnvironment(); + try { + getPwshTarget(); + } catch (e) { + expect(e instanceof ConfigurationError).toBe(true); + } + }); + + test('success on environment variables', () => { + deleteTargetOptionsFromEnvironment(); + setPwshEnvironmentVariables(); + getPwshTarget(); + }); +}); + +describe('config', () => { + function clearConfig(target: PowerShellTarget): void { + target.psConfig.apiKey = ''; + target.psConfig.repository = ''; + target.psConfig.module = ''; + } + + test('fails with missing config parameters', async () => { + const target = getPwshTarget(); + clearConfig(target); + try { + await target.publish('', ''); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationError); + expect(error.message).toBe( + 'Missing project configuration parameter(s): apiKey,repository,module'); + } + }); +}); + +describe('publish', () => { + const mockedSpawnProcess = spawnProcess as jest.Mock; + const spawnOptions = { enableInDryRunMode: true, showStdout: true } + + beforeEach(() => { + setPwshEnvironmentVariables(); + jest.clearAllMocks(); + }); + + + test('error on missing artifact', async () => { + const target = getPwshTarget(); + target.getArtifactsForRevision = jest.fn() + .mockImplementation(() => []).bind(PowerShellTarget); + + // `publish` should report an error. When it's not dry run, the error is + // thrown; when it's on dry run, the error is logged and `undefined` is + // returned. Thus, both alternatives have been considered. + try { + const noPackageFound = await target.publish('version', 'revision'); + expect(noPackageFound).toBe(undefined); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/there are no matching artifacts/); + } + }); + + test('error on having too many artifacts', async () => { + const target = getPwshTarget(); + target.getArtifactsForRevision = jest.fn() + .mockImplementation(() => ['file1', 'file2']).bind(PowerShellTarget); + + // `publish` should report an error. When it's not dry run, the error is + // thrown; when it's on dry run, the error is logged and `undefined` is + // returned. Thus, both alternatives have been considered. + try { + await target.publish('1.0', 'sha'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/found multiple matching artifacts/); + } + }); + + test('prints pwsh info', async () => { + const target = getPwshTarget(); + try { + await target.publish('1.0', 'sha'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/there are no matching artifact/); + } + expect(mockedSpawnProcess).toBeCalledWith('pwsh', ['--version'], {}, spawnOptions); + expect(mockedSpawnProcess).toBeCalledWith('pwsh', + [ + '-Command', + `$ErrorActionPreference = 'Stop' + + $info = Get-Command -Name Publish-Module + "Module name: $($info.ModuleName)" + "Module version: $($info.Module.Version)" + "Module path: $($info.Module.Path)" + ` + ], {}, spawnOptions); + }); + + test('publish-module runs with expected args', async () => { + const target = getPwshTarget(); + await target.publishModule('/path/to/module'); + expect(mockedSpawnProcess).toBeCalledWith('pwsh', + [ + '-Command', + `$ErrorActionPreference = 'Stop' + + Publish-Module -Path '/path/to/module' \` + -Repository 'repositoryName' \` + -NuGetApiKey 'test access key' \` + -WhatIf:$false + ` + ], {}, spawnOptions); + }); +}); diff --git a/src/targets/index.ts b/src/targets/index.ts index 86e6461a..6ca249d1 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -19,6 +19,7 @@ import { SymbolCollector } from './symbolCollector'; import { PubDevTarget } from './pubDev'; import { HexTarget } from './hex'; import { CommitOnGitRepositoryTarget } from './commitOnGitRepository'; +import { PowerShellTarget } from './powershell'; export const TARGET_MAP: { [key: string]: typeof BaseTarget } = { brew: BrewTarget, @@ -41,6 +42,7 @@ export const TARGET_MAP: { [key: string]: typeof BaseTarget } = { 'pub-dev': PubDevTarget, hex: HexTarget, 'commit-on-git-repository': CommitOnGitRepositoryTarget, + powershell: PowerShellTarget, }; /** Targets that are treated specially */ diff --git a/src/targets/powershell.ts b/src/targets/powershell.ts new file mode 100644 index 00000000..c7606bf8 --- /dev/null +++ b/src/targets/powershell.ts @@ -0,0 +1,144 @@ +import { join } from 'path'; +import { BaseArtifactProvider } from '../artifact_providers/base'; +import { TargetConfig } from '../schemas/project_config'; +import { ConfigurationError, reportError } from '../utils/errors'; +import { withTempDir } from '../utils/files'; +import { isDryRun } from '../utils/helpers'; +import { SpawnProcessOptions, checkExecutableIsPresent, extractZipArchive, spawnProcess } from '../utils/system'; +import { BaseTarget } from './base'; + +/** Command to launch PowerShell */ +export const POWERSHELL_BIN = process.env.POWERSHELL_BIN || 'pwsh'; + +/** Default repository */ +export const DEFAULT_POWERSHELL_REPOSITORY = 'PSGallery'; + +/** PowerShell target configuration options */ +export interface PowerShellTargetOptions { + /** API token */ + apiKey: string; + /** PowerShell repository name */ + repository: string; + /** Module name */ + module: string; +} + +/** + * Target responsible for publishing modules to a PowerShell repository + */ +export class PowerShellTarget extends BaseTarget { + /** Target name */ + public readonly name: string = 'powershell'; + /** Target options */ + public readonly psConfig: PowerShellTargetOptions; + private readonly defaultSpawnOptions = { enableInDryRunMode: true, showStdout: true } + + public constructor( + config: TargetConfig, + artifactProvider: BaseArtifactProvider + ) { + super(config, artifactProvider); + this.psConfig = { + apiKey: process.env.POWERSHELL_API_KEY || '', + repository: this.config.repository || DEFAULT_POWERSHELL_REPOSITORY, + module: this.config.module || '', + }; + checkExecutableIsPresent(POWERSHELL_BIN); + } + + /** + * Executes a PowerShell command. + */ + private async spawnPwsh( + command: string, + spawnProcessOptions: SpawnProcessOptions = this.defaultSpawnOptions + ): Promise { + command = `$ErrorActionPreference = 'Stop'\n` + command; + this.logger.trace("Executing PowerShell command:", command); + return spawnProcess(POWERSHELL_BIN, ['-Command', command], {}, spawnProcessOptions); + } + + /** + * Checks if the required project configuration parameters are available. + * The required parameters are `layerName` and `compatibleRuntimes`. + * There is also an optional parameter `includeNames`. + */ + private checkProjectConfig(): void { + const missingConfigOptions = []; + if (this.psConfig.apiKey.length === 0) { + missingConfigOptions.push('apiKey'); + } + if (this.psConfig.repository.length === 0) { + missingConfigOptions.push('repository'); + } + if (this.psConfig.module.length === 0) { + missingConfigOptions.push('module'); + } + if (missingConfigOptions.length > 0) { + throw new ConfigurationError( + 'Missing project configuration parameter(s): ' + missingConfigOptions + ); + } + } + + /** + * Publishes a module to a PowerShell repository. + * @param _version ignored; the version must be set in the module manifest. + * @param revision Git commit SHA to be published. + */ + public async publish(_version: string, revision: string): Promise { + this.checkProjectConfig(); + + // Emit the PowerShell executable for informational purposes. + this.logger.info(`PowerShell (${POWERSHELL_BIN}) info:`); + await spawnProcess(POWERSHELL_BIN, ['--version'], {}, this.defaultSpawnOptions); + + // Also check the command and its its module version in case there are issues: + this.logger.info('Publish-Module command info:'); + await this.spawnPwsh(` + $info = Get-Command -Name Publish-Module + "Module name: $($info.ModuleName)" + "Module version: $($info.Module.Version)" + "Module path: $($info.Module.Path)" + `); + + // Escape the given module artifact name to avoid regex issues. + let moduleArtifactRegex = `${this.psConfig.module}`.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); + moduleArtifactRegex = `/^${moduleArtifactRegex}\\.zip$/` + + this.logger.debug(`Looking for artifact matching ${moduleArtifactRegex}`); + const packageFiles = await this.getArtifactsForRevision(revision, { + includeNames: moduleArtifactRegex, + }); + if (!packageFiles.length) { + reportError( + `Cannot release the module to ${this.psConfig.repository}: there are no matching artifacts!` + ); + } else if (packageFiles.length > 1) { + reportError( + `Cannot release the module to ${this.psConfig.repository}: found multiple matching artifacts!` + ); + } + const artifact = packageFiles[0]; + const zipPath = await this.artifactProvider.downloadArtifact(artifact); + + this.logger.info(`Extracting artifact "${artifact.filename}"`) + await withTempDir(async dir => { + const moduleDir = join(dir, this.psConfig.module); + await extractZipArchive(zipPath, moduleDir); + await this.publishModule(moduleDir); + }); + + this.logger.info(`PowerShell module upload complete`); + } + + public async publishModule(moduleDir: string): Promise { + this.logger.info(`Publishing PowerShell module "${this.psConfig.module}" to ${this.psConfig.repository}`) + await this.spawnPwsh(` + Publish-Module -Path '${moduleDir}' \` + -Repository '${this.psConfig.repository}' \` + -NuGetApiKey '${this.psConfig.apiKey}' \` + -WhatIf:$${isDryRun()} + `); + } +}