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
48 changes: 45 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ on:
required: true
type: boolean

#permissions:
# id-token: write # Required for OIDC
# contents: read
permissions:
id-token: write # Required for OIDC
contents: read

jobs:
lint_and_unit_tests:
Expand Down Expand Up @@ -67,3 +67,45 @@ jobs:
run: yarn workspaces foreach --all --parallel --topological-dev --exclude @elastic/eui-website --exclude @elastic/eui-monorepo --exclude @elastic/eui-docgen run build
- name: Cypress tests
run: yarn workspaces foreach --all --parallel --topological-dev --exclude @elastic/eui-website --exclude @elastic/eui-monorepo --exclude @elastic/eui-docgen run test-cypress
release_snapshot:
name: Create a snapshot release
runs-on: ubuntu-latest
if: ${{ inputs.type == 'snapshot' }}
needs: [ lint_and_unit_tests, cypress_tests ]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.release_ref }}
# This is needed for yarn version to work properly, but it increases fetch time.
# We can change this back to "1" if we replace yarn version with something else
fetch-depth: 0
- name: Configure git
run: |
git config --global user.name 'EUI Machine'
git config --global user.email '[email protected]'
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: yarn
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm install -g [email protected] && yarn install --immutable
- name: Build release scripts
run: yarn workspace @elastic/eui-release-cli run build
- name: Rename git remote to upstream
run: git remote rename origin upstream
- name: Prepare list of workspaces
id: prepare_workspaces_arg
uses: actions/github-script@v8
env:
WORKSPACES: ${{ inputs.workspaces }}
with:
# language=javascript
script: |
if (!process.env.WORKSPACES || typeof process.env.WORKSPACES !== 'string') {
return '';
}
return `--workspaces ${process.env.WORKSPACES.split(',').join(' ')}`;
result-encoding: string
- name: Release
run: yarn release run snapshot --skip-prompts --skip-auth-check --use-auth-token --allow-custom ${{ inputs.dry_run && '--dry-run' || ''}} ${{ steps.prepare_workspaces_arg.outputs.result }}
23 changes: 20 additions & 3 deletions packages/release-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export const cli = () => {
})
.option('allowCustom', {
type: 'boolean',
description: '[UNSAFE!] Allow custom releases from unpushed changes. This should only be used with snapshot or custom releases',
description:
'[UNSAFE!] Allow custom releases from unpushed changes. This should only be used with snapshot or custom releases',
default: false,
})
.option('verbose', {
Expand All @@ -48,6 +49,11 @@ export const cli = () => {
description: 'Enable verbose logging',
default: false,
})
.option('dryRun', {
type: 'boolean',
description: 'Do not publish any packages to the npm registry',
default: false,
})
.option('skipPrompts', {
type: 'boolean',
description:
Expand All @@ -56,15 +62,22 @@ export const cli = () => {
})
.option('skipUpdateVersions', {
type: 'boolean',
description: '[UNSAFE!] Skip the update version step. This should only be used for special releases like backports. The --workspaces argument is required when this argument is set.',
description:
'[UNSAFE!] Skip the update version step. This should only be used for special releases like backports. The --workspaces argument is required when this argument is set.',
default: false,
})
.option('skipAuthCheck', {
type: 'boolean',
description:
'[UNSAFE!] Skip the registry authentication check during init. This should only be used with npm trusted publishing configured.',
default: false,
})
.option('useAuthToken', {
type: 'boolean',
description:
'Use npm auth token instead of the regular npm user authentication and one-time passwords (OTP). Use in CI only!',
default: false,
});
})
},
async (argv) => {
const {
Expand All @@ -73,9 +86,11 @@ export const cli = () => {
workspaces,
allowCustom,
verbose,
dryRun,
skipPrompts,
skipUpdateVersions,
useAuthToken,
skipAuthCheck,
} = argv;
const logger = new Logger(verbose);

Expand All @@ -85,9 +100,11 @@ export const cli = () => {
tag,
workspaces,
logger,
dryRun,
skipPrompts,
skipUpdateVersions,
useAuthToken,
skipAuthCheck,
allowCustomReleases: allowCustom,
});
} catch (err) {
Expand Down
36 changes: 35 additions & 1 deletion packages/release-cli/src/npm_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
*/

import { promisify } from 'node:util';
import { exec } from 'node:child_process';
import path from 'node:path';
import { exec, execSync } from 'node:child_process';

const execPromise = promisify(exec);

Expand All @@ -20,4 +21,37 @@ export const getNpmPublishedVersions = async (packageName: string) => {
}

return [];
};

export interface ExecPublish {
packageArchivePath: string;
otp: string | undefined;
dryRun: boolean;
tag: string;
}

export const npmExecPublish = ({
packageArchivePath,
otp,
dryRun,
tag,
}: ExecPublish) => {
if (!path.isAbsolute(packageArchivePath)) {
throw new Error('packageArchivePath is not an absolute path or is empty');
}

if (typeof otp === 'string' && !otp.length) {
throw new Error('OTP must be a non-empty string if defined');
}

if (!tag) {
throw new Error('tag must be defined');
}

const otpStr = otp ? `--otp ${otp}` : '';
const dryRunStr = dryRun ? '--dry-run' : '';
return execSync(
`npm publish ${packageArchivePath} --tag ${tag} --access public ${dryRunStr} ${otpStr}`,
{ stdio: 'inherit', encoding: 'utf8' }
);
};
12 changes: 11 additions & 1 deletion packages/release-cli/src/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,20 @@ export interface ReleaseOptions {
tag?: string;
workspaces?: string[];
logger: Logger;
dryRun: boolean;
allowCustomReleases: boolean;
skipPrompts: boolean;
skipUpdateVersions: boolean;
skipAuthCheck: boolean;
useAuthToken: boolean;
}

export const release = async (options: ReleaseOptions) => {
const { type, logger } = options;
const { dryRun, type, logger } = options;

if (dryRun) {
logger.warning('--dry-run is enabled. No packages will be published to the npm registry');
}

// Process tag
if (type === 'official') {
Expand Down Expand Up @@ -79,6 +85,10 @@ export const release = async (options: ReleaseOptions) => {
}
}

if (options.skipAuthCheck) {
logger.warning('--skip-auth-check is set');
}

const allWorkspaces = await getYarnWorkspaces();
let currentWorkspaces: Array<YarnWorkspace> = [];

Expand Down
24 changes: 14 additions & 10 deletions packages/release-cli/src/steps/init_checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,21 @@ export const stepInitChecks = async (options: ReleaseOptions) => {
)}) on branch ${chalk.underline.bold(currentBranch)}`
);

const registryUser = await getAuthenticatedUser();
if (!registryUser) {
throw new ValidationError(
'Authentication to npmjs is required. Please log in before running' +
' this command again.',
`To authenticate run the following command:\n` +
` ${chalk.yellowBright('yarn npm login')}`
);
}
if (!options.skipAuthCheck) {
const registryUser = await getAuthenticatedUser();
if (!registryUser) {
throw new ValidationError(
'Authentication to npmjs is required. Please log in before running' +
' this command again.',
`To authenticate run the following command:\n` +
` ${chalk.yellowBright('yarn npm login')}`
);
}

logger.info(`Logged in to npmjs as ${registryUser}`);
logger.info(`Logged in to npmjs as ${registryUser}`);
} else {
logger.info('Skipping the registry authentication check');
}

const npmRegistry = await getYarnRegistryServer();
if (npmRegistry) {
Expand Down
19 changes: 15 additions & 4 deletions packages/release-cli/src/steps/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import chalk from 'chalk';
import prompts from 'prompts';
import { type ReleaseOptions } from '../release';
import { getRootWorkspaceDir, getWorkspacePackageJson } from '../workspace';
import { execPublish, YarnWorkspace } from '../yarn_utils';
import { yarnPack, YarnWorkspace } from '../yarn_utils';
import { npmExecPublish } from '../npm_utils';

interface PublishedWorkspace extends YarnWorkspace {
version: string;
Expand All @@ -24,7 +25,7 @@ export const stepPublish = async (
options: ReleaseOptions,
workspacesToPublish: Array<YarnWorkspace>
) => {
const { logger } = options;
const { logger, dryRun } = options;
const rootWorkspaceDir = getRootWorkspaceDir();

const publishedWorkspaces: Array<PublishedWorkspace> = [];
Expand Down Expand Up @@ -53,8 +54,18 @@ export const stepPublish = async (
}

try {
// tag is always defined at this stage. See release.ts
execPublish(workspace.name, options.tag!, otp);
// We pack packages using yarn pack and publish using npm publish
// to be able to use npm trusted publishing and more
const packDetails = await yarnPack(workspace.name);
logger.info(`[${workspace.name}] Package successfully packed to "${packDetails.output}" with ${packDetails.files.length} files included`);

npmExecPublish({
packageArchivePath: packDetails.output,
// tag is always defined at this stage. See release.ts
tag: options.tag!,
dryRun,
otp,
});
} catch (err) {
logger.error(err);
logger.error(chalk.red(`[${workspace.name}] Failed to publish package`));
Expand Down
68 changes: 63 additions & 5 deletions packages/release-cli/src/yarn_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { promisify } from 'node:util';
import path from 'node:path';
import { exec, execSync } from 'node:child_process';

const execPromise = promisify(exec);
Expand Down Expand Up @@ -37,13 +38,70 @@ export const updateWorkspaceVersion = async (workspace: string, version: string)
return execPromise(`yarn workspace ${workspace} version ${version}`);
};

export const execPublish = (workspace: string, tag: string, otp?: string) => {
if (!tag) {
throw new Error('Tag must be defined');
export interface YarnPackRawDetail {
base?: string;
location?: string;
output?: string;
}

export interface YarnPackDetails {
/**
* An absolute base path to the workspace root directory
*/
base: string;
/**
* An array of absolute paths to files packed in the tgz archive
*/
files: string[];
/**
* An absolute path to the output tgz archive
*/
output: string;
}

export const yarnPack = async (workspace: string)=> {
const result = await execPromise(`yarn workspace ${workspace} pack --json`);
const rawDetails = JSON.parse(
`[${result.stdout.replace(/\n/g, ',').slice(0, -1)}]`
) as Array<YarnPackRawDetail>;
const details: YarnPackDetails = {
base: '',
files: [],
output: '',
};
for (const rawDetail of rawDetails) {
if (rawDetail.base) {
details.base = rawDetail.base;
}
if (rawDetail.location) {
details.files.push(rawDetail.location);
}
if (rawDetail.output) {
details.output = rawDetail.output;
}
}

// Validate the returned data
if (!details.base) {
throw new Error(
'yarn pack did not return the base path for the workspace. ' +
'This likely means that the command\'s JSON output changed format. ' +
'Please check the current yarn pack API and update the code '
);
}

if (!details.output) {
throw new Error(
'yarn pack did not return the path for the output tgz archive. ' +
'This likely means that the command\'s JSON output changed format. ' +
'Please check the current yarn pack API and update the code '
);
}

const otpStr = otp ? `--otp ${otp}` : '';
return execSync(`yarn workspace ${workspace} npm publish --access public --tag ${tag} ${otpStr}`, { stdio: 'inherit', encoding: 'utf8' });
// By default, the returned location property is a path relative
// to the workspace root directory. We want absolute paths instead.
details.files = details.files.map((file) => path.join(details.base, file));
return details;
};

export const getAuthenticatedUser = async () => {
Expand Down