Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: powershell target #515

Merged
merged 21 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()}
`);
}
}
Loading