Skip to content

Commit 7f398d6

Browse files
authored
[Automated releases] Snapshots release workflow (#9216)
1 parent 0a101c3 commit 7f398d6

File tree

7 files changed

+203
-27
lines changed

7 files changed

+203
-27
lines changed

.github/workflows/release.yml

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ on:
2424
required: true
2525
type: boolean
2626

27-
#permissions:
28-
# id-token: write # Required for OIDC
29-
# contents: read
27+
permissions:
28+
id-token: write # Required for OIDC
29+
contents: read
3030

3131
jobs:
3232
lint_and_unit_tests:
@@ -67,3 +67,45 @@ jobs:
6767
run: yarn workspaces foreach --all --parallel --topological-dev --exclude @elastic/eui-website --exclude @elastic/eui-monorepo --exclude @elastic/eui-docgen run build
6868
- name: Cypress tests
6969
run: yarn workspaces foreach --all --parallel --topological-dev --exclude @elastic/eui-website --exclude @elastic/eui-monorepo --exclude @elastic/eui-docgen run test-cypress
70+
release_snapshot:
71+
name: Create a snapshot release
72+
runs-on: ubuntu-latest
73+
if: ${{ inputs.type == 'snapshot' }}
74+
needs: [ lint_and_unit_tests, cypress_tests ]
75+
steps:
76+
- uses: actions/checkout@v4
77+
with:
78+
ref: ${{ inputs.release_ref }}
79+
# This is needed for yarn version to work properly, but it increases fetch time.
80+
# We can change this back to "1" if we replace yarn version with something else
81+
fetch-depth: 0
82+
- name: Configure git
83+
run: |
84+
git config --global user.name 'EUI Machine'
85+
git config --global user.email '[email protected]'
86+
- uses: actions/setup-node@v6
87+
with:
88+
node-version-file: .nvmrc
89+
cache: yarn
90+
registry-url: 'https://registry.npmjs.org'
91+
- name: Install dependencies
92+
run: npm install -g [email protected] && yarn install --immutable
93+
- name: Build release scripts
94+
run: yarn workspace @elastic/eui-release-cli run build
95+
- name: Rename git remote to upstream
96+
run: git remote rename origin upstream
97+
- name: Prepare list of workspaces
98+
id: prepare_workspaces_arg
99+
uses: actions/github-script@v8
100+
env:
101+
WORKSPACES: ${{ inputs.workspaces }}
102+
with:
103+
# language=javascript
104+
script: |
105+
if (!process.env.WORKSPACES || typeof process.env.WORKSPACES !== 'string') {
106+
return '';
107+
}
108+
return `--workspaces ${process.env.WORKSPACES.split(',').join(' ')}`;
109+
result-encoding: string
110+
- name: Release
111+
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 }}

packages/release-cli/src/cli.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ export const cli = () => {
3939
})
4040
.option('allowCustom', {
4141
type: 'boolean',
42-
description: '[UNSAFE!] Allow custom releases from unpushed changes. This should only be used with snapshot or custom releases',
42+
description:
43+
'[UNSAFE!] Allow custom releases from unpushed changes. This should only be used with snapshot or custom releases',
4344
default: false,
4445
})
4546
.option('verbose', {
@@ -48,6 +49,11 @@ export const cli = () => {
4849
description: 'Enable verbose logging',
4950
default: false,
5051
})
52+
.option('dryRun', {
53+
type: 'boolean',
54+
description: 'Do not publish any packages to the npm registry',
55+
default: false,
56+
})
5157
.option('skipPrompts', {
5258
type: 'boolean',
5359
description:
@@ -56,15 +62,22 @@ export const cli = () => {
5662
})
5763
.option('skipUpdateVersions', {
5864
type: 'boolean',
59-
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.',
65+
description:
66+
'[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.',
67+
default: false,
68+
})
69+
.option('skipAuthCheck', {
70+
type: 'boolean',
71+
description:
72+
'[UNSAFE!] Skip the registry authentication check during init. This should only be used with npm trusted publishing configured.',
6073
default: false,
6174
})
6275
.option('useAuthToken', {
6376
type: 'boolean',
6477
description:
6578
'Use npm auth token instead of the regular npm user authentication and one-time passwords (OTP). Use in CI only!',
6679
default: false,
67-
});
80+
})
6881
},
6982
async (argv) => {
7083
const {
@@ -73,9 +86,11 @@ export const cli = () => {
7386
workspaces,
7487
allowCustom,
7588
verbose,
89+
dryRun,
7690
skipPrompts,
7791
skipUpdateVersions,
7892
useAuthToken,
93+
skipAuthCheck,
7994
} = argv;
8095
const logger = new Logger(verbose);
8196

@@ -85,9 +100,11 @@ export const cli = () => {
85100
tag,
86101
workspaces,
87102
logger,
103+
dryRun,
88104
skipPrompts,
89105
skipUpdateVersions,
90106
useAuthToken,
107+
skipAuthCheck,
91108
allowCustomReleases: allowCustom,
92109
});
93110
} catch (err) {

packages/release-cli/src/npm_utils.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
*/
88

99
import { promisify } from 'node:util';
10-
import { exec } from 'node:child_process';
10+
import path from 'node:path';
11+
import { exec, execSync } from 'node:child_process';
1112

1213
const execPromise = promisify(exec);
1314

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

2223
return [];
24+
};
25+
26+
export interface ExecPublish {
27+
packageArchivePath: string;
28+
otp: string | undefined;
29+
dryRun: boolean;
30+
tag: string;
2331
}
32+
33+
export const npmExecPublish = ({
34+
packageArchivePath,
35+
otp,
36+
dryRun,
37+
tag,
38+
}: ExecPublish) => {
39+
if (!path.isAbsolute(packageArchivePath)) {
40+
throw new Error('packageArchivePath is not an absolute path or is empty');
41+
}
42+
43+
if (typeof otp === 'string' && !otp.length) {
44+
throw new Error('OTP must be a non-empty string if defined');
45+
}
46+
47+
if (!tag) {
48+
throw new Error('tag must be defined');
49+
}
50+
51+
const otpStr = otp ? `--otp ${otp}` : '';
52+
const dryRunStr = dryRun ? '--dry-run' : '';
53+
return execSync(
54+
`npm publish ${packageArchivePath} --tag ${tag} --access public ${dryRunStr} ${otpStr}`,
55+
{ stdio: 'inherit', encoding: 'utf8' }
56+
);
57+
};

packages/release-cli/src/release.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,20 @@ export interface ReleaseOptions {
2727
tag?: string;
2828
workspaces?: string[];
2929
logger: Logger;
30+
dryRun: boolean;
3031
allowCustomReleases: boolean;
3132
skipPrompts: boolean;
3233
skipUpdateVersions: boolean;
34+
skipAuthCheck: boolean;
3335
useAuthToken: boolean;
3436
}
3537

3638
export const release = async (options: ReleaseOptions) => {
37-
const { type, logger } = options;
39+
const { dryRun, type, logger } = options;
40+
41+
if (dryRun) {
42+
logger.warning('--dry-run is enabled. No packages will be published to the npm registry');
43+
}
3844

3945
// Process tag
4046
if (type === 'official') {
@@ -79,6 +85,10 @@ export const release = async (options: ReleaseOptions) => {
7985
}
8086
}
8187

88+
if (options.skipAuthCheck) {
89+
logger.warning('--skip-auth-check is set');
90+
}
91+
8292
const allWorkspaces = await getYarnWorkspaces();
8393
let currentWorkspaces: Array<YarnWorkspace> = [];
8494

packages/release-cli/src/steps/init_checks.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,21 @@ export const stepInitChecks = async (options: ReleaseOptions) => {
7575
)}) on branch ${chalk.underline.bold(currentBranch)}`
7676
);
7777

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

88-
logger.info(`Logged in to npmjs as ${registryUser}`);
89+
logger.info(`Logged in to npmjs as ${registryUser}`);
90+
} else {
91+
logger.info('Skipping the registry authentication check');
92+
}
8993

9094
const npmRegistry = await getYarnRegistryServer();
9195
if (npmRegistry) {

packages/release-cli/src/steps/publish.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import chalk from 'chalk';
1111
import prompts from 'prompts';
1212
import { type ReleaseOptions } from '../release';
1313
import { getRootWorkspaceDir, getWorkspacePackageJson } from '../workspace';
14-
import { execPublish, YarnWorkspace } from '../yarn_utils';
14+
import { yarnPack, YarnWorkspace } from '../yarn_utils';
15+
import { npmExecPublish } from '../npm_utils';
1516

1617
interface PublishedWorkspace extends YarnWorkspace {
1718
version: string;
@@ -24,7 +25,7 @@ export const stepPublish = async (
2425
options: ReleaseOptions,
2526
workspacesToPublish: Array<YarnWorkspace>
2627
) => {
27-
const { logger } = options;
28+
const { logger, dryRun } = options;
2829
const rootWorkspaceDir = getRootWorkspaceDir();
2930

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

5556
try {
56-
// tag is always defined at this stage. See release.ts
57-
execPublish(workspace.name, options.tag!, otp);
57+
// We pack packages using yarn pack and publish using npm publish
58+
// to be able to use npm trusted publishing and more
59+
const packDetails = await yarnPack(workspace.name);
60+
logger.info(`[${workspace.name}] Package successfully packed to "${packDetails.output}" with ${packDetails.files.length} files included`);
61+
62+
npmExecPublish({
63+
packageArchivePath: packDetails.output,
64+
// tag is always defined at this stage. See release.ts
65+
tag: options.tag!,
66+
dryRun,
67+
otp,
68+
});
5869
} catch (err) {
5970
logger.error(err);
6071
logger.error(chalk.red(`[${workspace.name}] Failed to publish package`));

packages/release-cli/src/yarn_utils.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { promisify } from 'node:util';
10+
import path from 'node:path';
1011
import { exec, execSync } from 'node:child_process';
1112

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

40-
export const execPublish = (workspace: string, tag: string, otp?: string) => {
41-
if (!tag) {
42-
throw new Error('Tag must be defined');
41+
export interface YarnPackRawDetail {
42+
base?: string;
43+
location?: string;
44+
output?: string;
45+
}
46+
47+
export interface YarnPackDetails {
48+
/**
49+
* An absolute base path to the workspace root directory
50+
*/
51+
base: string;
52+
/**
53+
* An array of absolute paths to files packed in the tgz archive
54+
*/
55+
files: string[];
56+
/**
57+
* An absolute path to the output tgz archive
58+
*/
59+
output: string;
60+
}
61+
62+
export const yarnPack = async (workspace: string)=> {
63+
const result = await execPromise(`yarn workspace ${workspace} pack --json`);
64+
const rawDetails = JSON.parse(
65+
`[${result.stdout.replace(/\n/g, ',').slice(0, -1)}]`
66+
) as Array<YarnPackRawDetail>;
67+
const details: YarnPackDetails = {
68+
base: '',
69+
files: [],
70+
output: '',
71+
};
72+
for (const rawDetail of rawDetails) {
73+
if (rawDetail.base) {
74+
details.base = rawDetail.base;
75+
}
76+
if (rawDetail.location) {
77+
details.files.push(rawDetail.location);
78+
}
79+
if (rawDetail.output) {
80+
details.output = rawDetail.output;
81+
}
82+
}
83+
84+
// Validate the returned data
85+
if (!details.base) {
86+
throw new Error(
87+
'yarn pack did not return the base path for the workspace. ' +
88+
'This likely means that the command\'s JSON output changed format. ' +
89+
'Please check the current yarn pack API and update the code '
90+
);
91+
}
92+
93+
if (!details.output) {
94+
throw new Error(
95+
'yarn pack did not return the path for the output tgz archive. ' +
96+
'This likely means that the command\'s JSON output changed format. ' +
97+
'Please check the current yarn pack API and update the code '
98+
);
4399
}
44100

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

49107
export const getAuthenticatedUser = async () => {

0 commit comments

Comments
 (0)