Skip to content
Closed
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
63 changes: 63 additions & 0 deletions .github/prepare-promote.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { execFileSync } from 'node:child_process';
import {
appendFileSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
unlinkSync,
writeFileSync
} from 'node:fs';
import { resolve } from 'node:path';

const run = (command, args, options = {}) => execFileSync(command, args, { encoding: 'utf8', ...options });
const directory = resolve(process.env.RELEASE_DIR);
mkdirSync(directory, { recursive: true });
if (readdirSync(directory).length) {
throw new Error('Promotion artifact directory must be empty');
}

const listPackages = () =>
JSON.parse(run('yarn', ['run', 'lerna', 'list', '--all', '--toposort', '--json', '--loglevel', 'silent']));
const before = listPackages();
// Preserve the stable versioning arguments from the original lerna publish.
// Lerna owns independent versions, changelogs, release commits, and tags.
run('yarn', ['run', 'lerna', 'version', '--conventional-commits', '--conventional-graduate', '--no-private', '--yes'], {
stdio: 'inherit'
});
const previousVersions = new Map(before.map((pkg) => [pkg.name, pkg.version]));
const gitHead = run('git', ['rev-parse', 'HEAD']).trim();
let count = 0;
for (const pkg of listPackages()) {
const manifestPath = resolve(pkg.location, 'package.json');
const original = readFileSync(manifestPath, 'utf8');
const manifest = JSON.parse(original);
if (manifest.private || previousVersions.get(pkg.name) === pkg.version) {
continue;
}
// Keep the dependency-first order when the publisher expands its archive glob.
const file = `package-${String(count).padStart(3, '0')}.tgz`;
const license = resolve(pkg.location, 'LICENSE');
const temporaryLicense = !existsSync(license);
try {
if (temporaryLicense) {
copyFileSync('LICENSE', license);
}
// Match Lerna's Git metadata and license; Yarn resolves workspace: ranges.
// All packing lifecycle scripts run here, without OIDC permission.
writeFileSync(manifestPath, `${JSON.stringify({ ...manifest, gitHead }, null, 2)}\n`);
run('yarn', ['workspace', pkg.name, 'pack', '--out', resolve(directory, file)], { stdio: 'inherit' });
} finally {
writeFileSync(manifestPath, original);
if (temporaryLicense && existsSync(license)) {
unlinkSync(license);
}
}
count++;
}

if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, `has-packages=${count > 0}\n`);
}
process.stdout.write(count ? `Prepared ${count} packages for promotion\n` : 'No changed packages to promote\n');
72 changes: 72 additions & 0 deletions .github/prepare-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { execFileSync } from 'node:child_process';
import {
appendFileSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
unlinkSync,
writeFileSync
} from 'node:fs';
import { resolve } from 'node:path';

const run = (command, args, options = {}) => execFileSync(command, args, { encoding: 'utf8', ...options });
const directory = resolve(process.env.RELEASE_DIR);
mkdirSync(directory, { recursive: true });
if (readdirSync(directory).length) {
throw new Error('Release artifact directory must be empty');
}

const listPackages = () =>
JSON.parse(run('yarn', ['run', 'lerna', 'list', '--all', '--toposort', '--json', '--loglevel', 'silent']));
const before = listPackages();
const subject = run('git', ['log', '-1', '--format=%s']);
const pr = subject.match(/#(\d+)/)?.[1];

// Lerna still owns package selection, independent versions, changelogs, commits,
// and tags. As with lerna publish, these Git updates precede npm publication.
run('yarn', ['run', 'lerna', 'version', 'prerelease', '--preid=prerelease', '--yes'], { stdio: 'inherit' });
const previousVersions = new Map(before.map((pkg) => [pkg.name, pkg.version]));
const gitHead = run('git', ['rev-parse', 'HEAD']).trim();
let count = 0;
for (const pkg of listPackages()) {
const manifestPath = resolve(pkg.location, 'package.json');
const original = readFileSync(manifestPath, 'utf8');
const manifest = JSON.parse(original);
if (manifest.private || previousVersions.get(pkg.name) === pkg.version) {
continue;
}
// Zero padding keeps the publishing glob in Lerna's dependency-first order.
const file = `package-${String(count).padStart(3, '0')}.tgz`;
// Yarn pack resolves workspace: ranges to the newly versioned dependencies.
// Any packing lifecycle scripts run here, never in the publishing job.
// Match Lerna's injected Git metadata and root license without committing them.
const license = resolve(pkg.location, 'LICENSE');
const temporaryLicense = !existsSync(license);
try {
if (temporaryLicense) {
copyFileSync('LICENSE', license);
}
writeFileSync(manifestPath, `${JSON.stringify({ ...manifest, gitHead }, null, 2)}\n`);
run('yarn', ['workspace', pkg.name, 'pack', '--out', resolve(directory, file)], { stdio: 'inherit' });
} finally {
writeFileSync(manifestPath, original);
if (temporaryLicense && existsSync(license)) {
unlinkSync(license);
}
}
count++;
}

if (count && pr) {
const comment = run('git', ['log', '-1', '--format=%b']);
writeFileSync(
resolve(directory, 'notification.json'),
JSON.stringify({ body: `Your changes have been released in: ${comment}Thanks for your contribution! :tada:` })
);
}
if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, `has-packages=${count > 0}\npr-number=${count && pr ? pr : ''}\n`);
}
process.stdout.write(count ? `Prepared ${count} packages\n` : 'No changed packages to publish\n');
25 changes: 17 additions & 8 deletions .github/promote.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#!/bin/bash
set -euo pipefail

GIT_USERNAME="patternfly-build"
GH_REPO=${GITHUB_REPOSITORY}
REPO="github.com/${GH_REPO}"
echo "Preparing release environment..."
git config user.email "patternfly-build@redhat.com"
git config user.name ${GIT_USERNAME}
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc

# Lerna is complicated. Commands: https://github.com/lerna/lerna/tree/master/commands
# Identify packages that have been updated since the previous tagged release
Expand All @@ -18,13 +19,21 @@ echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
# npm pkg set devDependencies.@patternfly/patternfly=${PATTERNFLY_VERSION} --workspace @patternfly/react-core --workspace @patternfly/react-styles --workspace @patternfly/react-tokens --workspace @patternfly/react-icons
#fi

# publish to npm
# yarn run lerna publish --conventional-commits --conventional-graduate --no-private --dist-tag=latest --yes

# immediately after promote - set up repo for next prerelease
yarn run lerna version preminor --force-publish --conventional-commits --no-private --yes --preid prerelease
case "${1:-prepare}" in
prepare)
# Uncomment to enable stable preparation. The workflow publishes the archives
# in a separate OIDC job before preparing the next prerelease cycle.
# node .github/prepare-promote.mjs
;;
next-prerelease)
# Immediately after successful promotion (or when stable publishing is disabled).
yarn run lerna version preminor --force-publish --conventional-commits --no-private --yes --preid prerelease
;;
*)
echo "Expected prepare or next-prerelease" >&2
exit 1
;;
esac

# dry run
# yarn run lerna version --conventional-commits --conventional-graduate --no-private --yes --no-git-tag-version --no-push


208 changes: 208 additions & 0 deletions .github/promote.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import test from 'node:test';
import yaml from 'js-yaml';

const workflow = yaml.load(readFileSync(new URL('./workflows/promote.yml', import.meta.url), 'utf8'));
const script = readFileSync(new URL('./promote.sh', import.meta.url), 'utf8');
const helper = resolve(import.meta.dirname, 'prepare-promote.mjs');
const publish = workflow.jobs.publish.steps.at(-1).run;
const fixture = () => {
const directory = mkdtempSync(resolve(process.env.RUNNER_TEMP || tmpdir(), 'promote-test-'));
mkdirSync(resolve(directory, 'bin'));
return directory;
};
const executable = (directory, name, body) => {
const file = resolve(directory, 'bin', name);
writeFileSync(file, `#!/usr/bin/env node\n${body}`);
chmodSync(file, 0o755);
};
const run = (command, args, directory) =>
spawnSync(command, args, {
cwd: directory,
encoding: 'utf8',
env: {
...process.env,
PATH: `${resolve(directory, 'bin')}:${process.env.PATH}`,
RELEASE_DIR: resolve(directory, 'promote'),
RUNNER_TEMP: directory,
GITHUB_OUTPUT: resolve(directory, 'github-output'),
GITHUB_REPOSITORY: 'patternfly/patternfly-react'
}
});

for (const scenario of ['changed', 'no-change', 'version-failure', 'pack-failure']) {
test(`stable preparation: ${scenario}`, () => {
const directory = fixture();
for (const name of ['public', 'private', 'unchanged']) {
mkdirSync(resolve(directory, name));
writeFileSync(resolve(directory, name, 'package.json'), JSON.stringify({ name, private: name === 'private' }));
}
writeFileSync(resolve(directory, 'LICENSE'), 'test license');
executable(directory, 'git', "console.log('abc123');");
executable(
directory,
'yarn',
`const fs = require('node:fs');
const assert = require('node:assert/strict');
const args = process.argv.slice(2);
if (args.includes('list')) {
console.log(JSON.stringify(['public', 'private', 'unchanged'].map(name => ({
name, version: fs.existsSync('versioned') && name !== 'unchanged' ? '1.1.0' : '1.1.0-prerelease.0', location: process.cwd() + '/' + name
}))));
} else if (args.includes('version')) {
assert.deepEqual(args, ['run', 'lerna', 'version', '--conventional-commits', '--conventional-graduate', '--no-private', '--yes']);
if (${JSON.stringify(scenario)} === 'version-failure') process.exit(17);
if (${JSON.stringify(scenario)} !== 'no-change') fs.writeFileSync('versioned', 'yes');
} else if (args.includes('pack')) {
const manifest = JSON.parse(fs.readFileSync(args[1] + '/package.json'));
assert.equal(manifest.gitHead, 'abc123');
assert.equal(fs.readFileSync(args[1] + '/LICENSE', 'utf8'), 'test license');
if (${JSON.stringify(scenario)} === 'pack-failure') process.exit(18);
fs.writeFileSync(args.at(-1), 'archive');
} else process.exit(99);`
);
// Enable stable preparation only in this fixture, never in the source script.
const enabled = resolve(directory, 'promote.sh');
writeFileSync(enabled, script.replace('# node .github/prepare-promote.mjs', `node "${helper}"`));
const result = run('bash', [enabled, 'prepare'], directory);
assert.equal(result.status, scenario.endsWith('failure') ? 1 : 0, result.stderr);
assert.equal(JSON.parse(readFileSync(resolve(directory, 'public/package.json'))).gitHead, undefined);
assert.equal(existsSync(resolve(directory, 'public/LICENSE')), false);
if (scenario.endsWith('failure')) {
assert.equal(existsSync(resolve(directory, 'github-output')), false);
return;
}
assert.deepEqual(readdirSync(resolve(directory, 'promote')), scenario === 'changed' ? ['package-000.tgz'] : []);
assert.equal(readFileSync(resolve(directory, 'github-output'), 'utf8'), `has-packages=${scenario === 'changed'}\n`);
});
}

test('stable preparation stays disabled by default, including with the optional input', () => {
const directory = fixture();
writeFileSync(resolve(directory, 'bin/git'), '#!/bin/bash\nexit 0\n');
chmodSync(resolve(directory, 'bin/git'), 0o755);
executable(directory, 'yarn', "require('node:fs').writeFileSync('unexpected-yarn', 'yes'); process.exit(99);");
writeFileSync(resolve(directory, 'bin/node'), '#!/bin/bash\ntouch unexpected-node\nexit 99\n');
chmodSync(resolve(directory, 'bin/node'), 0o755);
writeFileSync(resolve(directory, '.npmrc'), 'existing configuration\n');
const result = run(
'bash',
['-c', 'PATTERNFLY_VERSION=99.0.0 bash "$1" prepare', '--', resolve(import.meta.dirname, 'promote.sh')],
directory
);
assert.equal(result.status, 0, result.stderr);
for (const file of ['unexpected-yarn', 'unexpected-node', 'github-output', 'promote']) {
assert.equal(existsSync(resolve(directory, file)), false);
}
assert.equal(readFileSync(resolve(directory, '.npmrc'), 'utf8'), 'existing configuration\n');
});

for (const failure of [false, true]) {
test(`next prerelease: ${failure ? 'failure' : 'success'}`, () => {
const directory = fixture();
executable(directory, 'git', '');
executable(
directory,
'yarn',
`require('node:assert/strict').deepEqual(process.argv.slice(2), ['run', 'lerna', 'version', 'preminor', '--force-publish', '--conventional-commits', '--no-private', '--yes', '--preid', 'prerelease']); process.exit(${failure ? 42 : 0});`
);
const result = run('bash', [resolve(import.meta.dirname, 'promote.sh'), 'next-prerelease'], directory);
assert.equal(result.status, failure ? 42 : 0, result.stderr);
});
}

for (const scenario of ['success', 'partial-failure', 'missing-archives']) {
test(`publishing workflow step: ${scenario}`, () => {
const directory = fixture();
mkdirSync(resolve(directory, 'promote'));
if (scenario !== 'missing-archives') {
for (let i = 0; i < 12; i++) {
writeFileSync(resolve(directory, `promote/package-${String(i).padStart(3, '0')}.tgz`), 'archive');
}
}
executable(
directory,
'npm',
`const fs = require('node:fs');
fs.appendFileSync('calls', JSON.stringify(process.argv.slice(2)) + '\\n');
if (${scenario === 'partial-failure'} && process.argv[3].endsWith('package-001.tgz')) process.exit(42);`
);
const result = run('bash', ['-e', '-o', 'pipefail', '-c', publish], directory);
assert.equal(result.status === 0, scenario === 'success', result.stderr);
if (scenario === 'missing-archives') {
assert.equal(existsSync(resolve(directory, 'calls')), false);
return;
}
const calls = readFileSync(resolve(directory, 'calls'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(calls.length, scenario === 'success' ? 12 : 2);
for (const [index, args] of calls.entries()) {
assert.deepEqual(args, [
'publish',
resolve(directory, `promote/package-${String(index).padStart(3, '0')}.tgz`),
'--tag',
'latest',
'--access',
'public',
'--registry',
'https://registry.npmjs.org/',
'--ignore-scripts',
'--provenance'
]);
}
});
}

test('only isolated archive publication has OIDC permission', () => {
assert.deepEqual(workflow.permissions, { contents: 'read' });
assert.deepEqual(workflow.jobs.prepare.permissions, { contents: 'read' });
assert.deepEqual(workflow.jobs.publish.permissions, { contents: 'read', 'id-token': 'write' });
assert.deepEqual(workflow.jobs['next-prerelease'].permissions, { contents: 'read' });
assert(!JSON.stringify(workflow.jobs.publish).match(/secrets\.|setup-project|yarn|checkout/));
assert.equal(workflow.jobs.publish.steps.at(-1)['working-directory'], '${{ runner.temp }}');
assert.match(workflow.jobs.publish.steps[1].run, /npm install --global npm@11\.21\.1 --ignore-scripts/);
assert.equal(workflow.jobs.publish.needs, 'prepare');
assert.equal(workflow.jobs.publish.if, "needs.prepare.outputs.has-packages == 'true'");
assert.equal(workflow.jobs['next-prerelease'].steps[0].with.ref, '${{ github.ref_name }}');
});

test('automation suites run in PR CI, not production release or promotion jobs', () => {
const ci = yaml.load(readFileSync(new URL('./workflows/main.yml', import.meta.url), 'utf8'));
const release = yaml.load(readFileSync(new URL('./workflows/release.yml', import.meta.url), 'utf8'));
const job = ci.jobs['release-automation-tests'];
assert.equal(job.if, "github.event_name == 'pull_request'");
assert.deepEqual(job.permissions, { contents: 'read' });
assert.equal(job.steps[1].with['skip-build'], true);
assert.equal(job.steps.at(-1).run, 'node --test .github/release.test.mjs .github/promote.test.mjs');
for (const production of [release, workflow]) {
for (const job of Object.values(production.jobs)) {
for (const step of job.steps || []) {
assert(!step.run?.includes('node --test'));
}
}
}
});

test('next prerelease requires preparation success and publication success or deliberate skip', () => {
assert.deepEqual(workflow.jobs['next-prerelease'].needs, ['prepare', 'publish']);
const condition = workflow.jobs['next-prerelease'].if;
assert.equal(
condition.trim(),
"!cancelled() && needs.prepare.result == 'success' && (needs.publish.result == 'success' || needs.publish.result == 'skipped')"
);
// Evaluate the workflow's actual guard against every dependency outcome.
const shouldRun = new Function('needs', 'cancelled', `return (${condition});`);
for (const prepare of ['success', 'failure', 'skipped', 'cancelled']) {
for (const publish of ['success', 'failure', 'skipped', 'cancelled']) {
for (const cancelled of [false, true]) {
assert.equal(
shouldRun({ prepare: { result: prepare }, publish: { result: publish } }, () => cancelled),
!cancelled && prepare === 'success' && ['success', 'skipped'].includes(publish)
);
}
}
}
});
Loading
Loading