Skip to content

Commit

Permalink
feat(publish): Ability to merge to non-default (#245)
Browse files Browse the repository at this point in the history
This fixes #239 and fixes #115. This fix requires us to switch
from a purely GitHub API driven publish flow to a purely local
Git driven publish flow as it is not possible to compute the
nearest merge base via any GitHub API. We rely on `git log` and
some parsing to be able to determine this.

The patch also effectively reverts #220 as there is now no need
for that (we are operating on the repo directly).

Test run: https://github.com/getsentry/release-tester/commits/releases/0.4.x
Also see no merges to main: https://github.com/getsentry/release-tester/commits/main

Depends on getsentry/publish#334

TODO: re-read config once we switch branches.
  • Loading branch information
BYK authored May 27, 2021
1 parent af34026 commit 41eaa00
Show file tree
Hide file tree
Showing 12 changed files with 178 additions and 510 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.23.0

- feat(publish): Ability to merge to non-default (#245)

## 0.22.2

- fix(logging): Fix scoped loggers not respecting log level (#236)
Expand Down
15 changes: 0 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,21 +282,6 @@ github:
repo: sentry-javascript
```
If you are using Craft in a monorepo where each project has their own `.craft.yml`
configuration, you need to set the `github` information with the inclusion of
`projectPath`. Although Craft can infer the relative path of your project, it
cannot do this on sparse checkouts or when `.git` directory does not exist:

```yaml
github:
owner: getsentry
repo: sentry-ruby
projectPath: sentry-rails
```

This is used to determine the repo-relative location of the changelog in your
project.

### Pre-release Command
This command will run on your newly created release branch as part of `prepare`
Expand Down
32 changes: 8 additions & 24 deletions src/commands/prepare.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { existsSync, promises as fsPromises } from 'fs';
import { join, relative } from 'path';
import * as shellQuote from 'shell-quote';
import simpleGit, { SimpleGit, StatusResult } from 'simple-git';
import { SimpleGit, StatusResult } from 'simple-git';
import { Arguments, Argv, CommandBuilder } from 'yargs';

import {
getConfigFileDir,
getConfiguration,
DEFAULT_RELEASE_BRANCH_NAME,
getGlobalGithubConfig,
Expand All @@ -25,7 +24,7 @@ import {
handleGlobalError,
reportError,
} from '../utils/errors';
import { getDefaultBranch, getGithubClient } from '../utils/githubApi';
import { getGitClient, getDefaultBranch } from '../utils/git';
import { isDryRun, promptConfirmation } from '../utils/helpers';
import { formatJson } from '../utils/strings';
import { spawnProcess } from '../utils/system';
Expand Down Expand Up @@ -296,12 +295,13 @@ function checkGitStatus(repoStatus: StatusResult, rev: string) {
*
* @param newVersion Version to publish
*/
async function execPublish(newVersion: string): Promise<never> {
async function execPublish(remote: string, newVersion: string): Promise<never> {
logger.info('Running the "publish" command...');
const publishOptions: PublishOptions = {
remote,
newVersion,
keepBranch: false,
keepDownloads: false,
newVersion,
noMerge: false,
noStatusCheck: false,
};
Expand Down Expand Up @@ -448,27 +448,11 @@ export async function prepareMain(argv: PrepareOptions): Promise<any> {
// Get repo configuration
const config = getConfiguration();
const githubConfig = await getGlobalGithubConfig();

// Move to the directory where the config file is located
const configFileDir = getConfigFileDir() || '.';
process.chdir(configFileDir);
logger.debug(`Working directory:`, process.cwd());

const newVersion = argv.newVersion;

const git = simpleGit(configFileDir);
const isRepo = await git.checkIsRepo();
if (!isRepo) {
throw new ConfigurationError('Not in a git repository!');
}
const git = await getGitClient();

// Get some information about the Github project
const githubClient = getGithubClient();
const defaultBranch = await getDefaultBranch(
githubClient,
githubConfig.owner,
githubConfig.repo
);
const defaultBranch = await getDefaultBranch(git, argv.remote);
logger.debug(`Default branch for the repo:`, defaultBranch);
const repoStatus = await git.status();
const rev = argv.rev || repoStatus.current || defaultBranch;
Expand Down Expand Up @@ -527,7 +511,7 @@ export async function prepareMain(argv: PrepareOptions): Promise<any> {

if (argv.publish) {
logger.success(`Release branch "${branchName}" has been pushed.`);
await execPublish(newVersion);
await execPublish(argv.remote, newVersion);
} else {
logger.success(
'Done. Do not forget to run "craft publish" to publish the artifacts:',
Expand Down
157 changes: 97 additions & 60 deletions src/commands/publish.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as Github from '@octokit/rest';
import { Arguments, Argv, CommandBuilder } from 'yargs';
import chalk from 'chalk';
import {
Expand All @@ -19,13 +18,12 @@ import {
getGlobalGithubConfig,
} from '../config';
import { formatTable, logger } from '../logger';
import { GithubGlobalConfig, TargetConfig } from '../schemas/project_config';
import { TargetConfig } from '../schemas/project_config';
import { getAllTargetNames, getTargetByName, SpecialTarget } from '../targets';
import { BaseTarget } from '../targets/base';
import { handleGlobalError, reportError } from '../utils/errors';
import { withTempDir } from '../utils/files';
import { stringToRegexp } from '../utils/filters';
import { getGithubClient, mergeReleaseBranch } from '../utils/githubApi';
import { isDryRun, promptConfirmation } from '../utils/helpers';
import { formatSize, formatJson } from '../utils/strings';
import {
Expand All @@ -36,6 +34,8 @@ import {
import { isValidVersion } from '../utils/version';
import { BaseStatusProvider } from '../status_providers/base';
import { BaseArtifactProvider } from '../artifact_providers/base';
import { SimpleGit } from 'simple-git';
import { getGitClient, getDefaultBranch, stripRemoteName } from '../utils/git';

/** Default path to post-release script, relative to project root */
const DEFAULT_POST_RELEASE_SCRIPT_PATH = join('scripts', 'post-release.sh');
Expand Down Expand Up @@ -72,6 +72,17 @@ export const builder: CommandBuilder = (yargs: Argv) => {
'Source revision (git SHA or tag) to publish (if not release branch head)',
type: 'string',
})
.option('merge-target', {
alias: 'm',
description:
'Target branch to merge into. Uses the default branch from GitHub as a fallback',
type: 'string',
})
.option('remote', {
default: 'origin',
description: 'The git remote to use when pushing',
type: 'string',
})
.option('no-merge', {
default: false,
description: 'Do not merge the release branch after publishing',
Expand All @@ -98,8 +109,12 @@ export const builder: CommandBuilder = (yargs: Argv) => {

/** Command line options. */
export interface PublishOptions {
/** The git remote to use when pushing */
remote: string;
/** Revision to publish (can be commit, tag, etc.) */
rev?: string;
/** Target branch to merge the release into, auto detected when empty */
mergeTarget?: string;
/** One or more targets we want to publish */
target?: string | string[];
/** The new version to publish */
Expand Down Expand Up @@ -312,58 +327,82 @@ async function checkRevisionStatus(
await statusProvider.waitForTheBuildToSucceed(revision);
}

/**
* Determines the closest branch we can merge to from the current checkout
* Adapted from https://stackoverflow.com/a/55238339/90297
* @param git our local Git client
* @param remoteName Name of the remote to query for the default branch
* @returns
*/
async function getMergeTarget(
git: SimpleGit,
remoteName: string
): Promise<string> {
const logOutput = await git.raw(
'log',
'--decorate',
'--simplify-by-decoration',
'--oneline'
);
logger.debug('Trying to find merge target:');
logger.debug(logOutput);
const branchName =
stripRemoteName(
logOutput
.match(/^[\da-f]+ \((?!HEAD )([^)]+)\)/m)?.[1]
?.split(',', 1)?.[0],
remoteName
) || (await getDefaultBranch(git, remoteName));

if (!branchName) {
throw new Error('Cannot determine where to merge to!');
}

return branchName;
}

/**
* Deals with the release branch after publishing is done
*
* Leave the release branch unmerged, or merge it but not delete it if the
* corresponding flags are set.
*
* @param github Github client
* @param githubConfig Github repository configuration
* @param branchName Release branch name
* @param skipMerge If set to "true", the branch will not be merged
* @param git Git client
* @param remoteName The git remote name to interact with
* @param branch Name of the release branch
* @param [mergeTarget] Branch name to merge the release branch into
* @param keepBranch If set to "true", the branch will not be deleted
*/
async function handleReleaseBranch(
github: Github,
githubConfig: GithubGlobalConfig,
branchName: string,
skipMerge = false,
git: SimpleGit,
remoteName: string,
branch: string,
mergeTarget?: string,
keepBranch = false
): Promise<void> {
if (!branchName || skipMerge) {
logger.info('Skipping the merge step.');
return;
if (!mergeTarget) {
mergeTarget = await getMergeTarget(git, remoteName);
}
logger.debug(`Checking out merge target branch:`, mergeTarget);
await git.checkout(mergeTarget);

logger.debug(`Merging the release branch: ${branchName}`);
logger.debug(`Merging ${branch} into: ${mergeTarget}`);
if (!isDryRun()) {
await mergeReleaseBranch(
github,
githubConfig.owner,
githubConfig.repo,
branchName
);
await git
.pull(remoteName, mergeTarget, ['--rebase'])
.merge(['--no-ff', '--no-edit', branch])
.push(remoteName, mergeTarget);
} else {
logger.info('[dry-run] Not merging the release branch');
}

if (keepBranch) {
logger.info('Not deleting the release branch.');
} else {
const ref = `heads/${branchName}`;
logger.debug(`Deleting the release branch, ref: ${ref}`);
logger.debug(`Deleting the release branch: ${branch}`);
if (!isDryRun()) {
const response = await github.git.deleteRef({
owner: githubConfig.owner,
ref,
repo: githubConfig.repo,
});
logger.debug(
`Deleted ref "${ref}"`,
`Response status: ${response.status}`
);
logger.info(`Removed the remote branch: "${branchName}"`);
await git.branch(['-D', branch]).push([remoteName, '--delete', branch]);
logger.info(`Removed the remote branch: "${branch}"`);
} else {
logger.info('[dry-run] Not deleting the remote branch');
}
Expand Down Expand Up @@ -421,40 +460,34 @@ export async function runPostReleaseCommand(
export async function publishMain(argv: PublishOptions): Promise<any> {
// Get publishing configuration
const config = getConfiguration() || {};
const githubConfig = await getGlobalGithubConfig();
const githubClient = getGithubClient();

const newVersion = argv.newVersion;

logger.info(`Publishing version: "${newVersion}"`);

let revision: string;
const git = await getGitClient();

const rev = argv.rev;
let checkoutTarget;
let branchName;
if (argv.rev) {
branchName = '';
logger.debug(
`Fetching GitHub information for provided revision: "${argv.rev}"`
);
const response = await githubClient.repos.getCommit({
owner: githubConfig.owner,
ref: argv.rev,
repo: githubConfig.repo,
});
revision = response.data.sha;
if (rev) {
logger.debug(`Trying to get branch name for provided revision: "${rev}"`);
branchName = (
await git.raw('name-rev', '--name-only', '--no-undefined', rev)
).trim();
checkoutTarget = branchName || rev;
} else {
// Find the remote branch
const branchPrefix =
config.releaseBranchPrefix || DEFAULT_RELEASE_BRANCH_NAME;
branchName = `${branchPrefix}/${newVersion}`;

logger.debug('Fetching branch information', branchName);
const response = await githubClient.repos.getBranch({
branch: branchName,
owner: githubConfig.owner,
repo: githubConfig.repo,
});
revision = response.data.commit.sha;
checkoutTarget = branchName;
}

logger.debug('Checking out release branch', branchName);
await git.checkout(checkoutTarget);

const revision = await git.revparse('HEAD');
logger.debug('Revision to publish: ', revision);

const statusProvider = await getStatusProviderFromConfig();
Expand Down Expand Up @@ -552,19 +585,23 @@ export async function publishMain(argv: PublishOptions): Promise<any> {
logger.info(' ');
}

if (argv.rev) {
logger.info('Not merging any branches because revision was specified.');
if (argv.noMerge) {
logger.info('Not merging per user request via no-merge option.');
} else if (!branchName) {
logger.info(
'Not merging because cannot determine a branch name to merge from.'
);
} else if (
targetsToPublish.has(SpecialTarget.All) ||
targetsToPublish.has(SpecialTarget.None) ||
earlierStateExists
) {
// Publishing done, MERGE DAT BRANCH!
await handleReleaseBranch(
githubClient,
githubConfig,
git,
argv.remote,
branchName,
argv.noMerge,
argv.mergeTarget,
argv.keepBranch
);
if (!isDryRun()) {
Expand Down
7 changes: 0 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,18 +261,11 @@ export async function getGlobalGithubConfig(
}

if (remoteUrl?.source === 'github.com') {
const gitRoot = await git.revparse(['--show-toplevel']);
const projectPath = path.posix.format(
path.parse(path.relative(gitRoot, configDir))
);
repoGithubConfig = {
owner: remoteUrl.owner,
repo: remoteUrl.name,
projectPath,
};
}
} else if (!repoGithubConfig.projectPath) {
repoGithubConfig.projectPath = '.';
}

_globalGithubConfigCache = Object.freeze(repoGithubConfig);
Expand Down
2 changes: 2 additions & 0 deletions src/schemas/projectConfig.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const projectConfigJsonSchema = {
repo: {
type: 'string',
},
// TODO(byk): This is now obsolete, only in-place to keep bw compat
// deprecate and remove?
projectPath: {
type: 'string',
},
Expand Down
Loading

0 comments on commit 41eaa00

Please sign in to comment.