Skip to content

Commit

Permalink
feat: powershell target (#515)
Browse files Browse the repository at this point in the history
  • Loading branch information
vaind committed Mar 11, 2024
1 parent 0ffa646 commit 3de62e6
Show file tree
Hide file tree
Showing 5 changed files with 348 additions and 4 deletions.
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 '*'

Expand Down
40 changes: 36 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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 `<module>.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`:
Expand Down
159 changes: 159 additions & 0 deletions src/targets/__tests__/powershell.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions src/targets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 */
Expand Down
144 changes: 144 additions & 0 deletions src/targets/powershell.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer | undefined> {
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<any> {
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<void> {
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()}
`);
}
}

0 comments on commit 3de62e6

Please sign in to comment.