Skip to content
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
70 changes: 63 additions & 7 deletions .github/workflows/ci-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,76 @@ on:
paths-ignore:
- 'docs/**'

permissions:
actions: read
contents: read

jobs:
pr:
quality:
name: Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{github.event.pull_request.head.repo.full_name}}
fetch-depth: 0
Comment thread
coderabbitai[bot] marked this conversation as resolved.
persist-credentials: false
- uses: nrwl/nx-set-shas@v5
name: Derive appropriate SHAs for base and head for `nx affected` commands
- uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v5
with:
node-version: 'lts/*'
check-latest: true
package-manager-cache: false
- run: pnpm install --frozen-lockfile
- run: pnpm exec commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
- run: pnpm nx affected --target=format:check --parallel=2
- run: pnpm nx affected --target=lint --parallel=2

build-and-test:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
name: Build and test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{github.event.pull_request.head.repo.full_name}}
fetch-depth: 0
persist-credentials: false
- uses: nrwl/nx-set-shas@v5
name: Derive appropriate SHAs for base and head for `nx affected` commands
- uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v5
with:
node-version: 'lts/*'
check-latest: true
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm nx affected --target=build --parallel=3
- run: pnpm nx affected --target=test --parallel=2
env:
NODE_OPTIONS: '--throw-deprecation'

e2e:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
name: E2E
needs: [quality, build-and-test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.ref }}
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{github.event.pull_request.head.repo.full_name}}
fetch-depth: 0
persist-credentials: false
- uses: nrwl/nx-set-shas@v5
name: Derive appropriate SHAs for base and head for `nx affected` commands
- uses: pnpm/action-setup@v6
Expand All @@ -31,11 +92,6 @@ jobs:
echo ATLASSIAN_SITE_URL=${{ vars.ATLASSIAN_SITE_URL }} >> e2e/nx-forge-e2e/.env
echo DEVELOPER_SPACE_ID=${{ secrets.DEVELOPER_SPACE_ID }} >> e2e/nx-forge-e2e/.env
- run: pnpm install --frozen-lockfile
- run: pnpm exec commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
- run: pnpm nx affected --target=format:check --parallel=2
- run: pnpm nx affected --target=lint --parallel=2
- run: pnpm nx affected --target=build --parallel=3
- run: pnpm nx affected --target=test --parallel=2
- run: pnpm nx affected --target=e2e --parallel=2 --no-silent
env:
NODE_OPTIONS: '--no-deprecation'
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@swc-node/register": "catalog:",
"@swc/cli": "catalog:",
"@swc/core": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/fs-extra": "catalog:",
"@types/jest": "catalog:",
"@types/js-yaml": "catalog:",
Expand Down Expand Up @@ -69,6 +70,7 @@
},
"dependencies": {
"@forge/manifest": "catalog:",
"cross-spawn": "catalog:",
"fs-extra": "catalog:",
"js-yaml": "catalog:",
"jsonata": "catalog:"
Expand Down
1 change: 1 addition & 0 deletions packages/nx-forge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@nx/jest": "catalog:nx",
"@nx/js": "catalog:nx",
"@nx/webpack": "catalog:nx",
"cross-spawn": "catalog:",
"fs-extra": "catalog:",
"js-yaml": "catalog:",
"jsonata": "catalog:",
Expand Down
14 changes: 6 additions & 8 deletions packages/nx-forge/src/executors/deploy/executor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,22 +62,20 @@ describe('deploy executor', () => {
});

it('forwards multiple approval rules to Forge', async () => {
// Forge documents MAJOR_VERSION_RULE and accepts multiple server-provided rule names:
// https://developer.atlassian.com/platform/forge/cli-reference/deploy/#pre-approval
const approvals = ['MAJOR_VERSION_RULE', 'LICENSE_RULE'];

await runExecutor(
{
...defaultOptions,
approve: ['MAJOR_VERSION_RULE', 'LICENSE_RULE'],
approve: approvals,
},
context
);

expect(runForgeCommandAsyncMock).toHaveBeenCalledWith(
[
'deploy',
'--environment=development',
'--approve',
'MAJOR_VERSION_RULE',
'LICENSE_RULE',
],
['deploy', '--environment=development', '--approve', ...approvals],
{ cwd: 'dist/apps/example' }
);
});
Expand Down
62 changes: 62 additions & 0 deletions packages/nx-forge/src/executors/register/executor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { resolve } from 'path';
import { ExecutorContext } from '@nx/devkit';
import { runForgeCommandAsync } from '../../utils/forge/async-commands';
import runExecutor from './executor';
import { patchManifestYml } from './lib/patch-manifest-yml';
import { RegisterExecutorOptions } from './schema';

jest.mock('../../utils/forge/async-commands', () => ({
runForgeCommandAsync: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('./lib/patch-manifest-yml', () => ({
patchManifestYml: jest.fn().mockResolvedValue(undefined),
}));

const runForgeCommandAsyncMock = jest.mocked(runForgeCommandAsync);
const patchManifestYmlMock = jest.mocked(patchManifestYml);

describe('register executor', () => {
const context = {
root: '/workspace',
projectName: 'example',
projectsConfigurations: {
version: 2,
projects: {
example: {
root: 'apps/example',
sourceRoot: 'apps/example/src',
},
},
},
} as unknown as ExecutorContext;

const defaultOptions: RegisterExecutorOptions = {
outputPath: 'dist/apps/example',
appName: 'Example app',
verbose: false,
developerSpaceId: '',
acceptTerms: false,
};

beforeEach(() => {
jest.clearAllMocks();
});

it('forwards the developer space flag and value as separate arguments', async () => {
const developerSpaceId = 'space id;$(untrusted)';

await runExecutor(
{
...defaultOptions,
developerSpaceId,
},
context
);

expect(runForgeCommandAsyncMock).toHaveBeenCalledWith(
['register', '--developer-space-id', developerSpaceId, 'Example app'],
{ cwd: resolve('/workspace', 'dist/apps/example') }
);
expect(patchManifestYmlMock).toHaveBeenCalledTimes(1);
});
});
2 changes: 1 addition & 1 deletion packages/nx-forge/src/executors/register/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default async function runExecutor(
const args = [
'register',
...(options.developerSpaceId
? [`--developer-space-id ${options.developerSpaceId}`]
? ['--developer-space-id', options.developerSpaceId]
: []),
...(options.acceptTerms === true ? ['--accept-terms'] : []),
...(options.verbose === true ? ['--verbose'] : []),
Expand Down
160 changes: 160 additions & 0 deletions packages/nx-forge/src/utils/forge/async-commands.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import {
chmodSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { delimiter, join } from 'path';
import { getPackageManagerCommand } from '@nx/devkit';
import { runForgeCommandAsync } from './async-commands';

jest.mock('@nx/devkit', () => ({
...jest.requireActual('@nx/devkit'),
getPackageManagerCommand: jest.fn(),
}));

const getPackageManagerCommandMock = jest.mocked(getPackageManagerCommand);

describe('runForgeCommandAsync', () => {
let testRoot: string;
let binPath: string;
let invocationPath: string;
let sideEffectPath: string;

beforeEach(() => {
testRoot = mkdtempSync(join(tmpdir(), 'nx-forge-command-'));
binPath = join(testRoot, 'bin');
invocationPath = join(testRoot, 'invocation.json');
sideEffectPath = join(testRoot, 'shell-side-effect');
mkdirSync(binPath);

createPackageManagerShim(binPath, 'pnpm');
mockPackageManagerCommand('pnpm exec');
});

afterEach(() => {
rmSync(testRoot, { recursive: true, force: true });
jest.clearAllMocks();
});

it('preserves Forge arguments without shell interpretation', async () => {
const injectedCommand =
process.platform === 'win32'
? "approval value&node -e \"require('fs').writeFileSync(process.env.FORGE_SIDE_EFFECT,'created')\""
: "approval value;node -e \"require('fs').writeFileSync(process.env.FORGE_SIDE_EFFECT,'created')\"";
const forgeArgs = [
'deploy',
'--approve',
injectedCommand,
'--major-version',
'2',
];

await runForgeCommandAsync(forgeArgs, {
cwd: testRoot,
env: createEnvironment(binPath, invocationPath, {
FORGE_SIDE_EFFECT: sideEffectPath,
FORGE_TEST_ENV: 'forwarded',
}),
});

expect(JSON.parse(readFileSync(invocationPath, 'utf8'))).toEqual({
args: ['exec', 'forge', ...forgeArgs],
cwd: realpathSync(testRoot),
environmentValue: 'forwarded',
});
expect(existsSync(sideEffectPath)).toBe(false);
});

it.each([
{ exec: 'npx', executable: 'npx', prefixArgs: [] },
{ exec: 'pnpm exec', executable: 'pnpm', prefixArgs: ['exec'] },
{ exec: 'yarn', executable: 'yarn', prefixArgs: [] },
{ exec: 'bun', executable: 'bun', prefixArgs: [] },
])('supports the $exec package-manager command shape', async (command) => {
createPackageManagerShim(binPath, command.executable);
mockPackageManagerCommand(command.exec);

await runForgeCommandAsync(['version'], {
cwd: testRoot,
env: createEnvironment(binPath, invocationPath),
});

expect(JSON.parse(readFileSync(invocationPath, 'utf8')).args).toEqual([
...command.prefixArgs,
'forge',
'version',
]);
});

it('rejects when Forge exits with a non-zero code', async () => {
await expect(
runForgeCommandAsync(['lint'], {
cwd: testRoot,
env: createEnvironment(binPath, invocationPath, {
FORGE_EXIT_CODE: '7',
}),
})
).rejects.toThrow('Exit with error code: 7');
});
});

function mockPackageManagerCommand(exec: string): void {
getPackageManagerCommandMock.mockReturnValue({
exec,
} as ReturnType<typeof getPackageManagerCommand>);
}

function createEnvironment(
binPath: string,
invocationPath: string,
extraEnvironment: NodeJS.ProcessEnv = {}
): NodeJS.ProcessEnv {
const pathKey =
Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ??
'PATH';

return {
[pathKey]: `${binPath}${delimiter}${process.env[pathKey] ?? ''}`,
FORGE_INVOCATION_PATH: invocationPath,
...extraEnvironment,
};
}

function createPackageManagerShim(directory: string, command: string): void {
const scriptPath = join(directory, 'fake-package-manager.cjs');
writeFileSync(
scriptPath,
[
"const { writeFileSync } = require('fs');",
'writeFileSync(',
' process.env.FORGE_INVOCATION_PATH,',
' JSON.stringify({',
' args: process.argv.slice(2),',
' cwd: process.cwd(),',
' environmentValue: process.env.FORGE_TEST_ENV,',
' })',
');',
'process.exitCode = Number(process.env.FORGE_EXIT_CODE ?? 0);',
].join('\n')
);

if (process.platform === 'win32') {
writeFileSync(
join(directory, `${command}.cmd`),
`@echo off\r\n"${process.execPath}" "%~dp0\\fake-package-manager.cjs" %*\r\n`
);
} else {
const executablePath = join(directory, command);
writeFileSync(
executablePath,
`#!/usr/bin/env node\nrequire('./fake-package-manager.cjs');\n`
);
chmodSync(executablePath, 0o755);
}
}
Loading