Skip to content

Commit

Permalink
feat: support prereleases and allow multiple starting points
Browse files Browse the repository at this point in the history
  • Loading branch information
iowillhoit committed Dec 13, 2022
1 parent f32337f commit 7e59d22
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 93 deletions.
16 changes: 10 additions & 6 deletions messages/cli.release.build.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
{
"description": "creates a PR to the repository property defined in the package.json to release a latest-rc build",
"description": "builds a new release from a designated starting point and optionally creates PR in Github",
"examples": [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> --patch",
"<%= config.bin %> <%= command.id %> --rc-tag 7.177.0 --patch",
"<%= config.bin %> <%= command.id %> --rc-tag latest-rc",
"<%= config.bin %> <%= command.id %> --start-from-npm-dist-tag latest-rc --patch",
"<%= config.bin %> <%= command.id %> --start-from-github-ref 7.144.0",
"<%= config.bin %> <%= command.id %> --start-from-github-ref main",
"<%= config.bin %> <%= command.id %> --start-from-github-ref f476e8e",
"<%= config.bin %> <%= command.id %> --start-from-github-ref main --prerelease beta",
"<%= config.bin %> <%= command.id %> --build-only",
"<%= config.bin %> <%= command.id %> --only @salesforce/plugin-source,@salesforce/plugin-info@1.2.3,@sf/config"
],
"flags": {
"rcTag": "the tag to start the rc from. Can be semver or a dist-tag, examples: 7.177.0, nightly, latest-rc",
"base": "the base branch for the PR",
"startFromNpmDistTag": "the npm dist-tag to start the release from, examples: nightly, latest-rc",
"startFromGithubRef": "a Github ref to start the release from, examples: main, 7.144.0, f476e8e",
"resolutions": "bump the versions of packages listed in the resolutions section",
"pinnedDeps": "bump the versions of the packages listed in the pinnedDependencies section",
"only": "only bump the version of the packages passed in, uses latest if version is not provided",
"patch": "bump the release as a patch of an existing version, not a new minor version",
"buildOnly": "only build the latest rc, do not git add/commit/push",
"prerelease": "name of the prerelease to create, examples: dev, alpha",
"buildOnly": "only build the release, do not git add/commit/push",
"snapshot": "update the snapshots and commit them to the PR",
"schema": "update the schemas and commit them to the PR"
}
Expand Down
109 changes: 86 additions & 23 deletions src/commands/cli/release/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import * as os from 'os';
import { arrayWithDeprecation, Flags, SfCommand, Ux } from '@salesforce/sf-plugins-core';
import { exec, ExecOptions } from 'shelljs';
import { exec, ExecOptions, set } from 'shelljs';
import { ensureString } from '@salesforce/ts-types';
import { Env } from '@salesforce/kit';
import { Octokit } from '@octokit/core';
Expand All @@ -24,15 +24,18 @@ export default class build extends SfCommand<void> {
public static readonly examples = messages.getMessage('examples').split(os.EOL);
public static readonly aliases = ['cli:latestrc:build'];
public static readonly flags = {
base: Flags.string({
summary: messages.getMessage('flags.base'),
default: 'main',
}),
'rc-tag': Flags.string({
summary: messages.getMessage('flags.rcTag'),
default: 'latest-rc',
'start-from-npm-dist-tag': Flags.string({
summary: messages.getMessage('flags.startFromNpmDistTag'),
// default: 'latest-rc', // TODO: Will need to update this in GHA before next RC. `exactlyOne` does not work wellwith defaults
char: 'd',
aliases: ['rctag'],
deprecateAliases: true,
exactlyOne: ['start-from-npm-dist-tag', 'start-from-github-ref'],
}),
'start-from-github-ref': Flags.string({
summary: messages.getMessage('flags.startFromGithubRef'),
char: 'g',
exactlyOne: ['start-from-npm-dist-tag', 'start-from-github-ref'],
}),
'build-only': Flags.boolean({
summary: messages.getMessage('flags.buildOnly'),
Expand All @@ -53,6 +56,11 @@ export default class build extends SfCommand<void> {
}),
patch: Flags.boolean({
summary: messages.getMessage('flags.patch'),
exclusive: ['prerelease'],
}),
prerelease: Flags.string({
summary: messages.getMessage('flags.prerelease'),
exclusive: ['patch'],
}),
snapshot: Flags.boolean({
summary: messages.getMessage('flags.snapshot'),
Expand All @@ -63,9 +71,18 @@ export default class build extends SfCommand<void> {
};

public async run(): Promise<void> {
// I could not get the shelljs.exec config { fatal: true } to actually throw an error, but this works :shrug:
set('-e');

let auth: string;
const { flags } = await this.parse(build);

if (flags.prerelease === 'true' || flags.prerelease === 'false') {
throw new SfError(
'The prerelease flag is not a boolean. It should be the name of the prerelease tag, examples: dev, alpha, beta'
);
}

const pushChangesToGitHub = !flags['build-only'];

if (pushChangesToGitHub) {
Expand All @@ -75,25 +92,62 @@ export default class build extends SfCommand<void> {
);
}

const { ['rc-tag']: rcTag } = flags;
const { ['start-from-npm-dist-tag']: startFromNpmDistTag, ['start-from-github-ref']: startFromGithubRef } = flags;

let ref: string;

if (startFromGithubRef) {
this.log(`Flag '--start-from-github-ref' passed, switching to '${startFromGithubRef}'`);

ref = startFromGithubRef;
} else {
this.log(`Flag '--start-from-npm-dist-tag' passed, looking up version for ${startFromNpmDistTag}`);

// Classes... I wish this was just a helper function.
const temp = await PackageRepo.create({ ux: new Ux({ jsonEnabled: this.jsonEnabled() }) });
const version = temp.package.getDistTags(temp.package.packageJson.name)[startFromNpmDistTag];

ref = version;
}

// Check out "starting point"
// Works with sha (detached): "git checkout f476e8e"
// Works with remote branch: "git checkout my-branch"
// Works with tag (detached): "git checkout 7.174.0"
this.exec(`git checkout ${ref}`);

const repo = await PackageRepo.create({ ux: new Ux({ jsonEnabled: this.jsonEnabled() }) });

// Get the current version for the passed in tag
// Get the current version for the "starting point"
const currentVersion = repo.package.packageJson.version;

// TODO: We might want to check and see if nextVersion exists
// Determine the next version based on if --patch was passed in or if it is a prerelease
const [currentVersion, nextVersion] = repo.package.getVersionsForTag(rcTag, flags.patch);
const nextVersion = repo.package.determineNextVersion(flags.patch, flags.prerelease);
repo.nextVersion = nextVersion;

this.log(`Starting on ${currentVersion} (${rcTag}) and creating branch ${repo.nextVersion}`);
// Prereleases and patches need special branch prefixes to trigger GitHub Actions
const branchPrefix = flags.patch ? 'patch/' : flags.prerelease ? 'prerelease/' : '';

const branchName = `${branchPrefix}${nextVersion}`;

this.log(`Starting from '${ref}' (${currentVersion}) and creating branch '${branchName}'`);

// Ensure we have a list of all tags pulled
this.exec('git fetch --all --tags');
// Start the rc build process on the current version for that tag
// Also, create a new branch that matches the next version
this.exec(`git checkout ${currentVersion} -b ${nextVersion}`);
// Create a new branch that matches the next version
this.exec(`git switch -c ${branchName}`);

if (flags.patch && pushChangesToGitHub) {
// Since patches can be created from any previous dist-tag or github ref,
// it is unlikely that we would be able to merge these into main.
// Before we make any changes, push this branch to use as our PR `base`.
// The build-patch.yml GHA will watch for merges into this branch to trigger a patch release
// TODO: ^ update this GHA reference once it is decided

this.exec(`git push -u origin ${branchName}`);
}

// bump the version in the pjson to the next version for this tag
this.log(`setting the version to ${nextVersion}`);
this.log(`Setting the version to ${nextVersion}`);
repo.package.setNextVersion(nextVersion);
repo.package.packageJson.version = nextVersion;

Expand Down Expand Up @@ -142,19 +196,28 @@ export default class build extends SfCommand<void> {

// commit package.json/yarn.lock and potentially command-snapshot changes
this.exec('git add .');
this.exec(`git commit -m "chore(${rcTag}): bump to ${nextVersion}"`);
this.exec(`git push --set-upstream origin ${nextVersion} --no-verify`, { silent: false });
this.exec(`git commit -m "chore(release): bump to ${nextVersion}"`);
this.exec(`git push --set-upstream origin ${branchName} --no-verify`, { silent: false });

const repoOwner = repo.package.packageJson.repository.split('/')[0];
const repoName = repo.package.packageJson.repository.split('/')[1];

// TODO: Review this after prerelease flow is solidified
const prereleaseDetails =
'\n**IMPORTANT:**\nPrereleases work differently than regular releases. Github Actions watches for branches prefixed with `prerelease/`. As long as the `package.json` contains a valid "prerelease tag" (1.2.3-dev.0), a new prerelease will be created for EVERY COMMIT pushed to that branch. If you would like to merge this PR into `main`, simply push one more commit that sets the version in the `package.json` to the version you\'d like to release.';

// If it is a patch, we will set the PR base to the prefixed branch we pushed earlier
// The Github Action will watch the `patch/` prefix for changes
const base = flags.patch ? `${branchName}` : 'main';

await octokit.request(`POST /repos/${repoOwner}/${repoName}/pulls`, {
owner: repoOwner,
repo: repoName,
head: nextVersion,
base: flags.base,
title: `Release v${nextVersion} as ${rcTag}`,
body: `Building ${rcTag} [skip-validate-pr]`,
base,
// TODO: Will need to update the "Tag kickoff" that is looking for this specific string
title: `Release PR for ${nextVersion}`,
body: `Building ${nextVersion} [skip-validate-pr]${flags.prerelease ? prereleaseDetails : ''}`,
});
}
}
Expand Down
16 changes: 4 additions & 12 deletions src/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,20 +264,12 @@ export class Package extends AsyncOptionalCreatable {
.filter(Boolean); // remove falsy values, in this case the `undefined` if version did not change
}

public getVersionsForTag(tag: string, isPatch = false): string[] {
const version = semver.valid(tag) ? tag : this.getDistTags(this.packageJson.name)[tag];
const currentVersion = semver.parse(version);
public determineNextVersion(isPatch = false, prerelease?: string): string {
const currentVersion = this.packageJson.version;

if (!currentVersion) {
throw new SfError(`Unable to parse valid semver from '${tag}'`);
}

const isPrerelease = semver.prerelease(currentVersion);
const releaseType = isPrerelease ? 'prerelease' : isPatch ? 'patch' : 'minor';

const nextVersion = semver.inc(currentVersion, releaseType);
const releaseType = prerelease ? 'prerelease' : isPatch ? 'patch' : 'minor';

return [currentVersion.version, nextVersion];
return semver.inc(currentVersion, releaseType, prerelease);
}

public pinDependencyVersions(targetTag: string): ChangedPackageVersions {
Expand Down
83 changes: 31 additions & 52 deletions test/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,81 +301,60 @@ describe('Package', () => {
});
});

describe('getVersionsForTag', () => {
beforeEach(() => {
stubMethod($$.SANDBOX, Package.prototype, 'getDistTags').returns({
latest: '1.2.3',
dev: '1.2.3-beta.0',
});
});
describe('determineNextVersion', () => {
it('bumps minor', async () => {
stubMethod($$.SANDBOX, Package.prototype, 'readPackageJson').returns(
Promise.resolve({ name: pkgName, version: '1.2.3' })
);

it('bumps minor from dist tag', async () => {
const pkg = await Package.create();
const results = pkg.getVersionsForTag('latest');
const results = pkg.determineNextVersion();

expect(results).to.deep.equal(['1.2.3', '1.3.0']);
expect(results).to.deep.equal('1.3.0');
});

it('bumps patch from dist tag', async () => {
const pkg = await Package.create();
const results = pkg.getVersionsForTag('latest', true);

expect(results).to.deep.equal(['1.2.3', '1.2.4']);
});
it('bumps patch', async () => {
stubMethod($$.SANDBOX, Package.prototype, 'readPackageJson').returns(
Promise.resolve({ name: pkgName, version: '1.2.3' })
);

it('bumps prerelease from dist tag', async () => {
const pkg = await Package.create();
const results = pkg.getVersionsForTag('dev');
const results = pkg.determineNextVersion(true);

expect(results).to.deep.equal(['1.2.3-beta.0', '1.2.3-beta.1']);
expect(results).to.deep.equal('1.2.4');
});

it('throws an error for invalid tag', async () => {
const pkg = await Package.create();

try {
pkg.getVersionsForTag('foo');
} catch (err) {
expect(err.message).to.deep.equal("Unable to parse valid semver from 'foo'");
}
});
it('supports semver with v prefix', async () => {
stubMethod($$.SANDBOX, Package.prototype, 'readPackageJson').returns(
Promise.resolve({ name: pkgName, version: 'v1.2.3' })
);

it('bumps minor from semver', async () => {
const pkg = await Package.create();
const results = pkg.getVersionsForTag('4.5.6');
const results = pkg.determineNextVersion();

expect(results).to.deep.equal(['4.5.6', '4.6.0']);
expect(results).to.deep.equal('1.3.0');
});

it('bumps patch from semver', async () => {
const pkg = await Package.create();
const results = pkg.getVersionsForTag('4.5.6', true);

expect(results).to.deep.equal(['4.5.6', '4.5.7']);
});
it('bumps prerelease from standard version', async () => {
stubMethod($$.SANDBOX, Package.prototype, 'readPackageJson').returns(
Promise.resolve({ name: pkgName, version: '1.2.3' })
);

it('bumps prerelease from semver', async () => {
const pkg = await Package.create();
const results = pkg.getVersionsForTag('4.5.6-alpha.0');
const results = pkg.determineNextVersion(false, 'beta');

expect(results).to.deep.equal(['4.5.6-alpha.0', '4.5.6-alpha.1']);
expect(results).to.deep.equal('1.2.4-beta.0');
});

it('supports semver with v prefix', async () => {
const pkg = await Package.create();
const results = pkg.getVersionsForTag('v4.5.6', true);

expect(results).to.deep.equal(['4.5.6', '4.5.7']);
});
it('bumps prerelease from existing prerelease', async () => {
stubMethod($$.SANDBOX, Package.prototype, 'readPackageJson').returns(
Promise.resolve({ name: pkgName, version: '1.2.4-beta.0' })
);

it('throws an error for invalid semver', async () => {
const pkg = await Package.create();
const results = pkg.determineNextVersion(false, 'beta');

try {
pkg.getVersionsForTag('1.a.3');
} catch (err) {
expect(err.message).to.deep.equal("Unable to parse valid semver from '1.a.3'");
}
expect(results).to.deep.equal('1.2.4-beta.1');
});
});
});

0 comments on commit 7e59d22

Please sign in to comment.