feat(backend-github): Open Authoring (#2430)

* Make filterPromises resolve entries before filtering

* Add filterPromisesWith & onlySuccessfulPromises to utility library

* Memoize user method in GitHub API

* Make storeMetadata safe to call concurrently in GitHub API

* Fork workflow: startup and authentication

* Fork workflow: backend support

* Fork workflow: disable unused UI elements

* Fork workflow: docs

* Fork workflow: fix deploy previews

* Suggested edits for fork workflow doc

* Change future tense to present

* Fork workflow: add beta status to docs

* remove debug statement

* rename fork workflow to Open Authoring
This commit is contained in:
Benaiah Mischenko 2019-07-24 15:20:41 -07:00 committed by Shawn Erquhart
parent 41559256d0
commit edf0a3afdc
16 changed files with 729 additions and 219 deletions

View File

@ -1,28 +1,39 @@
import { Base64 } from 'js-base64';
import { uniq, initial, last, get, find, hasIn, partial, result } from 'lodash';
import semaphore from 'semaphore';
import { find, flow, get, hasIn, initial, last, partial, result, uniq } from 'lodash';
import { map } from 'lodash/fp';
import {
localForage,
filterPromises,
resolvePromiseProperties,
APIError,
EditorialWorkflowError,
filterPromisesWith,
localForage,
onlySuccessfulPromises,
resolvePromiseProperties,
} from 'netlify-cms-lib-util';
const CMS_BRANCH_PREFIX = 'cms/';
const replace404WithEmptyArray = err => (err && err.status === 404 ? [] : Promise.reject(err));
export default class API {
constructor(config) {
this.api_root = config.api_root || 'https://api.github.com';
this.token = config.token || false;
this.branch = config.branch || 'master';
this.originRepo = config.originRepo;
this.useForkWorkflow = config.useForkWorkflow;
this.repo = config.repo || '';
this.repoURL = `/repos/${this.repo}`;
this.originRepoURL = this.originRepo && `/repos/${this.originRepo}`;
this.merge_method = config.squash_merges ? 'squash' : 'merge';
this.initialWorkflowStatus = config.initialWorkflowStatus;
}
user() {
return this.request('/user');
if (!this._userPromise) {
this._userPromise = this.request('/user');
}
return this._userPromise;
}
hasWriteAccess() {
@ -94,8 +105,32 @@ export default class API {
});
}
generateBranchName(basename) {
return `${CMS_BRANCH_PREFIX}${basename}`;
generateContentKey(collectionName, slug) {
if (!this.useForkWorkflow) {
// this doesn't use the collection, but we need to leave it that way for backwards
// compatibility
return slug;
}
return `${this.repo}/${collectionName}/${slug}`;
}
generateBranchNameFromCollectionAndSlug(collectionName, slug) {
return this.generateContentKey(collectionName, slug).then(contentKey =>
this.generateBranchName(contentKey),
);
}
generateBranchName(contentKey) {
return `${CMS_BRANCH_PREFIX}${contentKey}`;
}
branchNameFromRef(ref) {
return ref.substring('refs/heads/'.length - 1);
}
contentKeyFromRef(ref) {
return ref.substring(`refs/heads/${CMS_BRANCH_PREFIX}/`.length - 1);
}
checkMetadataRef() {
@ -125,27 +160,39 @@ export default class API {
});
}
storeMetadata(key, data) {
return this.checkMetadataRef().then(branchData => {
const fileTree = {
[`${key}.json`]: {
path: `${key}.json`,
raw: JSON.stringify(data),
file: true,
},
};
return this.uploadBlob(fileTree[`${key}.json`])
.then(() => this.updateTree(branchData.sha, '/', fileTree))
.then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree))
.then(response => this.patchRef('meta', '_netlify_cms', response.sha))
.then(() => {
async storeMetadata(key, data) {
// semaphore ensures metadata updates are always ordered, even if
// calls to storeMetadata are not. concurrent metadata updates
// will result in the metadata branch being unable to update.
if (!this._metadataSemaphore) {
this._metadataSemaphore = semaphore(1);
}
return new Promise((resolve, reject) =>
this._metadataSemaphore.take(async () => {
try {
const branchData = await this.checkMetadataRef();
const fileTree = {
[`${key}.json`]: {
path: `${key}.json`,
raw: JSON.stringify(data),
file: true,
},
};
await this.uploadBlob(fileTree[`${key}.json`]);
const changeTree = await this.updateTree(branchData.sha, '/', fileTree);
const { sha } = await this.commit(`Updating “${key}” metadata`, changeTree);
await this.patchRef('meta', '_netlify_cms', sha);
localForage.setItem(`gh.meta.${key}`, {
expires: Date.now() + 300000, // In 5 minutes
data,
});
});
});
this._metadataSemaphore.leave();
resolve();
} catch (err) {
reject(err);
}
}),
);
}
retrieveMetadata(key) {
@ -158,11 +205,27 @@ export default class API {
'%c Checking for MetaData files',
'line-height: 30px;text-align: center;font-weight: bold',
);
return this.request(`${this.repoURL}/contents/${key}.json`, {
const metadataRequestOptions = {
params: { ref: 'refs/meta/_netlify_cms' },
headers: { Accept: 'application/vnd.github.VERSION.raw' },
cache: 'no-store',
})
};
if (!this.useForkWorkflow) {
return this.request(`${this.repoURL}/contents/${key}.json`, metadataRequestOptions)
.then(response => JSON.parse(response))
.catch(() =>
console.log(
'%c %s does not have metadata',
'line-height: 30px;text-align: center;font-weight: bold',
key,
),
);
}
const [user, repo] = key.split('/');
return this.request(`/repos/${user}/${repo}/contents/${key}.json`, metadataRequestOptions)
.then(response => JSON.parse(response))
.catch(() =>
console.log(
@ -174,11 +237,11 @@ export default class API {
});
}
readFile(path, sha, branch = this.branch) {
readFile(path, sha, { branch = this.branch, repoURL = this.repoURL } = {}) {
if (sha) {
return this.getBlob(sha);
} else {
return this.request(`${this.repoURL}/contents/${path}`, {
return this.request(`${repoURL}/contents/${path}`, {
headers: { Accept: 'application/vnd.github.VERSION.raw' },
params: { ref: branch },
cache: 'no-store',
@ -229,10 +292,19 @@ export default class API {
const metaDataPromise = this.retrieveMetadata(contentKey).then(data =>
data.objects.entry.path ? data : Promise.reject(null),
);
const repoURL = this.useForkWorkflow
? `/repos/${contentKey
.split('/')
.slice(0, 2)
.join('/')}`
: this.repoURL;
return resolvePromiseProperties({
metaData: metaDataPromise,
fileData: metaDataPromise.then(data =>
this.readFile(data.objects.entry.path, null, data.branch),
this.readFile(data.objects.entry.path, null, {
branch: data.branch,
repoURL,
}),
),
isModification: metaDataPromise.then(data =>
this.isUnpublishedEntryModification(data.objects.entry.path, this.branch),
@ -243,7 +315,10 @@ export default class API {
}
isUnpublishedEntryModification(path, branch) {
return this.readFile(path, null, branch)
return this.readFile(path, null, {
branch,
repoURL: this.useForkWorkflow ? this.originRepoURL : this.repoURL,
})
.then(() => true)
.catch(err => {
if (err.message && err.message === 'Not Found') {
@ -253,35 +328,107 @@ export default class API {
});
}
listUnpublishedBranches() {
getPRsForBranchName = ({
branchName,
state,
base = this.branch,
repoURL = this.repoURL,
usernameOfFork,
} = {}) => {
// Get PRs with a `head` of `branchName`. Note that this is a
// substring match, so we need to check that the `head.ref` of
// at least one of the returned objects matches `branchName`.
// TODO: this is a paginated endpoint
return this.request(`${repoURL}/pulls`, {
params: {
head: usernameOfFork ? `${usernameOfFork}:${branchName}` : branchName,
...(state ? { state } : {}),
base,
},
});
};
branchHasPR = async ({ branchName, ...rest }) => {
const prs = await this.getPRsForBranchName({ branchName, ...rest });
return prs.some(pr => this.branchNameFromRef(pr.head.ref) === branchName);
};
getUpdatedForkWorkflowMetadata = async (contentKey, { metadata: metadataArg } = {}) => {
const metadata = metadataArg || (await this.retrieveMetadata(contentKey)) || {};
const { pr: prMetadata, status } = metadata;
// Set the status to draft if no corresponding PR is recorded
if (!prMetadata && status !== 'draft') {
const newMetadata = { ...metadata, status: 'draft' };
this.storeMetadata(contentKey, newMetadata);
return newMetadata;
}
// If no status is recorded, but there is a PR, check if the PR is
// closed or not and update the status accordingly.
if (prMetadata) {
const { number: prNumber } = prMetadata;
const originPRInfo = await this.request(`${this.originRepoURL}/pulls/${prNumber}`);
const { state: currentState, merged_at: mergedAt } = originPRInfo;
if (currentState === 'closed' && mergedAt) {
// The PR has been merged; delete the unpublished entry
const [, collectionName, slug] = contentKey.split('/');
this.deleteUnpublishedEntry(collectionName, slug);
return;
} else if (currentState === 'closed' && !mergedAt) {
if (status !== 'draft') {
const newMetadata = { ...metadata, status: 'draft' };
await this.storeMetadata(contentKey, newMetadata);
return newMetadata;
}
} else {
if (status !== 'pending_review') {
// PR is open and has not been merged
const newMetadata = { ...metadata, status: 'pending_review' };
await this.storeMetadata(contentKey, newMetadata);
return newMetadata;
}
}
}
return metadata;
};
async listUnpublishedBranches() {
console.log(
'%c Checking for Unpublished entries',
'line-height: 30px;text-align: center;font-weight: bold',
);
return this.request(`${this.repoURL}/git/refs/heads/cms`)
.then(branches =>
filterPromises(branches, branch => {
const branchName = branch.ref.substring('/refs/heads/'.length - 1);
// Get PRs with a `head` of `branchName`. Note that this is a
// substring match, so we need to check that the `head.ref` of
// at least one of the returned objects matches `branchName`.
return this.request(`${this.repoURL}/pulls`, {
params: {
head: branchName,
state: 'open',
base: this.branch,
},
}).then(prs => prs.some(pr => pr.head.ref === branchName));
}),
)
.catch(error => {
console.log(
'%c No Unpublished entries',
'line-height: 30px;text-align: center;font-weight: bold',
);
throw error;
});
const onlyBranchesWithOpenPRs = filterPromisesWith(({ ref }) =>
this.branchHasPR({ branchName: this.branchNameFromRef(ref), state: 'open' }),
);
const getUpdatedForkWorkflowBranches = flow([
map(async branch => {
const contentKey = this.contentKeyFromRef(branch.ref);
const metadata = await this.getUpdatedForkWorkflowMetadata(contentKey);
// filter out removed entries
if (!metadata) {
return Promise.reject('Unpublished entry was removed');
}
return branch;
}),
onlySuccessfulPromises,
]);
try {
const branches = await this.request(`${this.repoURL}/git/refs/heads/cms`).catch(
replace404WithEmptyArray,
);
const filterFunction = this.useForkWorkflow
? getUpdatedForkWorkflowBranches
: onlyBranchesWithOpenPRs;
return await filterFunction(branches);
} catch (err) {
console.log(
'%c No Unpublished entries',
'line-height: 30px;text-align: center;font-weight: bold',
);
throw err;
}
}
/**
@ -289,8 +436,16 @@ export default class API {
* concept of entry "status". Useful for things like deploy preview links.
*/
async getStatuses(sha) {
const resp = await this.request(`${this.repoURL}/commits/${sha}/status`);
return resp.statuses;
const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL;
try {
const resp = await this.request(`${repoURL}/commits/${sha}/status`);
return resp.statuses;
} catch (err) {
if (err && err.message && err.message === 'Ref not found') {
return [];
}
throw err;
}
}
composeFileTree(files) {
@ -372,84 +527,71 @@ export default class API {
});
}
editorialWorkflowGit(fileTree, entry, filesList, options) {
const contentKey = entry.slug;
async editorialWorkflowGit(fileTree, entry, filesList, options) {
const contentKey = this.generateContentKey(options.collectionName, entry.slug);
const branchName = this.generateBranchName(contentKey);
const unpublished = options.unpublished || false;
const branchData = await this.getBranch();
if (!unpublished) {
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch`
let prResponse;
return this.getBranch()
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(commitResponse => this.createBranch(branchName, commitResponse.sha))
.then(() => this.createPR(options.commitMessage, branchName))
.then(pr => {
prResponse = pr;
return this.user();
})
.then(user => {
return this.storeMetadata(contentKey, {
type: 'PR',
pr: {
number: prResponse.number,
head: prResponse.head && prResponse.head.sha,
},
user: user.name || user.login,
status: this.initialWorkflowStatus,
branch: branchName,
collection: options.collectionName,
title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description,
objects: {
entry: {
path: entry.path,
sha: entry.sha,
},
files: filesList,
},
timeStamp: new Date().toISOString(),
});
});
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch
const userPromise = this.user();
const changeTree = await this.updateTree(branchData.commit.sha, '/', fileTree);
const commitResponse = await this.commit(options.commitMessage, changeTree);
await this.createBranch(branchName, commitResponse.sha);
const pr = this.useForkWorkflow
? undefined
: await this.createPR(options.commitMessage, branchName);
const user = await userPromise;
return this.storeMetadata(contentKey, {
type: 'PR',
pr: pr
? {
number: pr.number,
head: pr.head && pr.head.sha,
}
: undefined,
user: user.name || user.login,
status: this.initialWorkflowStatus,
branch: branchName,
collection: options.collectionName,
commitMessage: options.commitMessage,
title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description,
objects: {
entry: {
path: entry.path,
sha: entry.sha,
},
files: filesList,
},
timeStamp: new Date().toISOString(),
});
} else {
// Entry is already on editorial review workflow - just update metadata and commit to existing branch
let newHead;
return this.getBranch(branchName)
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(commit => {
newHead = commit;
return this.retrieveMetadata(contentKey);
})
.then(metadata => {
const { title, description } = options.parsedData || {};
const metadataFiles = get(metadata.objects, 'files', []);
const files = [...metadataFiles, ...filesList];
const pr = { ...metadata.pr, head: newHead.sha };
const objects = {
entry: { path: entry.path, sha: entry.sha },
files: uniq(files),
};
const updatedMetadata = { ...metadata, pr, title, description, objects };
const changeTree = await this.updateTree(branchData.commit.sha, '/', fileTree);
const commitPromise = this.commit(options.commitMessage, changeTree);
const metadataPromise = this.retrieveMetadata(contentKey);
const [commit, metadata] = await Promise.all([commitPromise, metadataPromise]);
const { title, description } = options.parsedData || {};
const metadataFiles = get(metadata.objects, 'files', []);
const files = [...metadataFiles, ...filesList];
const pr = metadata.pr ? { ...metadata.pr, head: commit.sha } : undefined;
const objects = {
entry: { path: entry.path, sha: entry.sha },
files: uniq(files),
};
const updatedMetadata = { ...metadata, pr, title, description, objects };
/**
* If an asset store is in use, assets are always accessible, so we
* can just finish the persist operation here.
*/
if (options.hasAssetStore) {
return this.storeMetadata(contentKey, updatedMetadata).then(() =>
this.patchBranch(branchName, newHead.sha),
);
}
if (options.hasAssetStore) {
await this.storeMetadata(contentKey, updatedMetadata);
return this.patchBranch(branchName, commit.sha);
}
/**
* If no asset store is in use, assets are being stored in the content
* repo, which means pull requests opened for editorial workflow
* entries must be rebased if assets have been added or removed.
*/
return this.rebasePullRequest(pr.number, branchName, contentKey, metadata, newHead);
});
if (pr) {
return this.rebasePullRequest(pr.number, branchName, contentKey, metadata, commit);
}
return this.storeMetadata(contentKey, updatedMetadata);
}
}
@ -568,14 +710,16 @@ export default class API {
* Get a pull request by PR number.
*/
getPullRequest(prNumber) {
return this.request(`${this.repoURL}/pulls/${prNumber} }`);
const repoURL = this.useForkWorkflow ? this.repoURL : this.originRepoURL;
return this.request(`${repoURL}/pulls/${prNumber} }`);
}
/**
* Get the list of commits for a given pull request.
*/
getPullRequestCommits(prNumber) {
return this.request(`${this.repoURL}/pulls/${prNumber}/commits`);
const repoURL = this.useForkWorkflow ? this.repoURL : this.originRepoURL;
return this.request(`${repoURL}/pulls/${prNumber}/commits`);
}
/**
@ -596,22 +740,61 @@ export default class API {
throw Error('Editorial workflow branch changed unexpectedly.');
}
updateUnpublishedEntryStatus(collection, slug, status) {
const contentKey = slug;
return this.retrieveMetadata(contentKey)
.then(metadata => ({
async updateUnpublishedEntryStatus(collectionName, slug, status) {
const contentKey = this.generateContentKey(collectionName, slug);
const metadata = await this.retrieveMetadata(contentKey);
if (!this.useForkWorkflow) {
return this.storeMetadata(contentKey, {
...metadata,
status,
}))
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata));
});
}
if (status === 'pending_publish') {
throw new Error('Fork workflow entries may not be set to the status "pending_publish".');
}
const { pr: prMetadata } = metadata;
if (prMetadata) {
const { number: prNumber } = prMetadata;
const originPRInfo = await this.request(`${this.originRepoURL}/pulls/${prNumber}`);
const { state } = originPRInfo;
if (state === 'open' && status === 'draft') {
await this.closePR(prMetadata);
return this.storeMetadata(contentKey, {
...metadata,
status,
});
}
if (state === 'closed' && status === 'pending_review') {
await this.openPR(prMetadata);
return this.storeMetadata(contentKey, {
...metadata,
status,
});
}
}
if (!prMetadata && status === 'pending_review') {
const branchName = this.generateBranchName(contentKey);
const commitMessage = metadata.commitMessage || 'Automatically generated by Netlify CMS';
const { number, head } = await this.createPR(commitMessage, branchName);
return this.storeMetadata(contentKey, {
...metadata,
pr: { number, head },
status,
});
}
}
deleteUnpublishedEntry(collection, slug) {
const contentKey = slug;
async deleteUnpublishedEntry(collectionName, slug) {
const contentKey = this.generateContentKey(collectionName, slug);
const branchName = this.generateBranchName(contentKey);
return (
this.retrieveMetadata(contentKey)
.then(metadata => this.closePR(metadata.pr))
.then(metadata => (metadata && metadata.pr ? this.closePR(metadata.pr) : Promise.resolve()))
.then(() => this.deleteBranch(branchName))
// If the PR doesn't exist, then this has already been deleted -
// deletion should be idempotent, so we can consider this a
@ -620,13 +803,14 @@ export default class API {
if (err.message === 'Reference does not exist') {
return Promise.resolve();
}
console.error(err);
return Promise.reject(err);
})
);
}
publishUnpublishedEntry(collection, slug) {
const contentKey = slug;
const contentKey = this.generateContentKey(collection.get('name'), slug);
const branchName = this.generateBranchName(contentKey);
return this.retrieveMetadata(contentKey)
.then(metadata => this.mergePR(metadata.pr, metadata.objects))
@ -678,21 +862,36 @@ export default class API {
return this.deleteRef('heads', branchName);
}
createPR(title, head, base = this.branch) {
async createPR(title, head, base = this.branch) {
const body = 'Automatically generated by Netlify CMS';
return this.request(`${this.repoURL}/pulls`, {
const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL;
const headReference = this.useForkWorkflow ? `${(await this.user()).login}:${head}` : head;
return this.request(`${repoURL}/pulls`, {
method: 'POST',
body: JSON.stringify({ title, body, head, base }),
body: JSON.stringify({ title, body, head: headReference, base }),
});
}
async openPR(pullRequest) {
const { number } = pullRequest;
const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL;
console.log('%c Re-opening PR', 'line-height: 30px;text-align: center;font-weight: bold');
return this.request(`${repoURL}/pulls/${number}`, {
method: 'PATCH',
body: JSON.stringify({
state: 'open',
}),
});
}
closePR(pullrequest) {
const prNumber = pullrequest.number;
const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL;
console.log('%c Deleting PR', 'line-height: 30px;text-align: center;font-weight: bold');
return this.request(`${this.repoURL}/pulls/${prNumber}`, {
return this.request(`${repoURL}/pulls/${prNumber}`, {
method: 'PATCH',
body: JSON.stringify({
state: closed,
state: 'closed',
}),
});
}
@ -700,8 +899,9 @@ export default class API {
mergePR(pullrequest, objects) {
const headSha = pullrequest.head;
const prNumber = pullrequest.number;
const repoURL = this.useForkWorkflow ? this.originRepoURL : this.repoURL;
console.log('%c Merging PR', 'line-height: 30px;text-align: center;font-weight: bold');
return this.request(`${this.repoURL}/pulls/${prNumber}/merge`, {
return this.request(`${repoURL}/pulls/${prNumber}/merge`, {
method: 'PUT',
body: JSON.stringify({
commit_message: 'Automatically generated. Merged on Netlify CMS.',

View File

@ -9,6 +9,18 @@ const LoginButtonIcon = styled(Icon)`
margin-right: 18px;
`;
const ForkApprovalContainer = styled.div`
display: flex;
flex-flow: column nowrap;
justify-content: space-around;
flex-grow: 0.2;
`;
const ForkButtonsContainer = styled.div`
display: flex;
flex-flow: row nowrap;
justify-content: space-around;
`;
export default class GitHubAuthenticationPage extends React.Component {
static propTypes = {
onLogin: PropTypes.func.isRequired,
@ -22,6 +34,35 @@ export default class GitHubAuthenticationPage extends React.Component {
state = {};
getPermissionToFork = () => {
return new Promise((resolve, reject) => {
this.setState({
requestingFork: true,
approveFork: () => {
this.setState({ requestingFork: false });
resolve();
},
refuseFork: () => {
this.setState({ requestingFork: false });
reject();
},
});
});
};
loginWithForkWorkflow(data) {
const { backend } = this.props;
this.setState({ findingFork: true });
return backend
.authenticateWithFork({ userData: data, getPermissionToFork: this.getPermissionToFork })
.catch(err => {
this.setState({ findingFork: false });
console.error(err);
throw err;
});
}
handleLogin = e => {
e.preventDefault();
const cfg = {
@ -39,23 +80,57 @@ export default class GitHubAuthenticationPage extends React.Component {
this.setState({ loginError: err.toString() });
return;
}
if (this.props.config.getIn(['backend', 'fork_workflow'])) {
return this.loginWithForkWorkflow(data).then(() => this.props.onLogin(data));
}
this.props.onLogin(data);
});
};
renderLoginButton = () =>
this.props.inProgress || this.state.findingFork ? (
'Logging in...'
) : (
<React.Fragment>
<LoginButtonIcon type="github" />
{' Login with GitHub'}
</React.Fragment>
);
getAuthenticationPageRenderArgs() {
const { requestingFork } = this.state;
if (requestingFork) {
const { approveFork, refuseFork } = this.state;
return {
renderPageContent: ({ LoginButton }) => (
<ForkApprovalContainer>
<p>Forking workflow is enabled: we need to use a fork on your github account.</p>
<ForkButtonsContainer>
<LoginButton onClick={approveFork}>Fork the repo</LoginButton>
<LoginButton onClick={refuseFork}>Don&#39;t fork the repo</LoginButton>
</ForkButtonsContainer>
</ForkApprovalContainer>
),
};
}
return {
renderButtonContent: this.renderLoginButton,
};
}
render() {
const { inProgress, config } = this.props;
const { loginError, requestingFork, findingFork } = this.state;
return (
<AuthenticationPage
onLogin={this.handleLogin}
loginDisabled={inProgress}
loginErrorMessage={this.state.loginError}
loginDisabled={inProgress || findingFork || requestingFork}
loginErrorMessage={loginError}
logoUrl={config.get('logo_url')}
renderButtonContent={() => (
<React.Fragment>
<LoginButtonIcon type="github" /> {inProgress ? 'Logging in...' : 'Login with GitHub'}
</React.Fragment>
)}
{...this.getAuthenticationPageRenderArgs()}
/>
);
}

View File

@ -1,3 +1,4 @@
import React from 'react';
import trimStart from 'lodash/trimStart';
import semaphore from 'semaphore';
import { stripIndent } from 'common-tags';
@ -49,7 +50,17 @@ export default class GitHub {
this.api = this.options.API || null;
this.repo = config.getIn(['backend', 'repo'], '');
this.forkWorkflowEnabled = config.getIn(['backend', 'fork_workflow'], false);
if (this.forkWorkflowEnabled) {
if (!this.options.useWorkflow) {
throw new Error(
'backend.fork_workflow is true but publish_mode is not set to editorial_workflow.',
);
}
this.originRepo = config.getIn(['backend', 'repo'], '');
} else {
this.repo = config.getIn(['backend', 'repo'], '');
}
this.branch = config.getIn(['backend', 'branch'], 'master').trim();
this.api_root = config.getIn(['backend', 'api_root'], 'https://api.github.com');
this.token = '';
@ -57,11 +68,89 @@ export default class GitHub {
}
authComponent() {
return AuthenticationPage;
const wrappedAuthenticationPage = props => <AuthenticationPage {...props} backend={this} />;
wrappedAuthenticationPage.displayName = 'AuthenticationPage';
return wrappedAuthenticationPage;
}
restoreUser(user) {
return this.authenticate(user);
return this.forkWorkflowEnabled
? this.authenticateWithFork({ userData: user, getPermissionToFork: () => true }).then(() =>
this.authenticate(user),
)
: this.authenticate(user);
}
async pollUntilForkExists({ repo, token }) {
const pollDelay = 250; // milliseconds
var repoExists = false;
while (!repoExists) {
repoExists = await fetch(`${this.api_root}/repos/${repo}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(() => true)
.catch(err => (err && err.status === 404 ? false : Promise.reject(err)));
// wait between polls
if (!repoExists) {
await new Promise(resolve => setTimeout(resolve, pollDelay));
}
}
return Promise.resolve();
}
async currentUser({ token }) {
if (!this._currentUserPromise) {
this._currentUserPromise = fetch(`${this.api_root}/user`, {
headers: {
Authorization: `Bearer ${token}`,
},
}).then(res => res.json());
}
return this._currentUserPromise;
}
async userIsOriginMaintainer({ username: usernameArg, token }) {
const username = usernameArg || (await this.currentUser({ token })).login;
this._userIsOriginMaintainerPromises = this._userIsOriginMaintainerPromises || {};
if (!this._userIsOriginMaintainerPromises[username]) {
this._userIsOriginMaintainerPromises[username] = fetch(
`${this.api_root}/repos/${this.originRepo}/collaborators/${username}/permission`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
)
.then(res => res.json())
.then(({ permission }) => permission === 'admin' || permission === 'write');
}
return this._userIsOriginMaintainerPromises[username];
}
async authenticateWithFork({ userData, getPermissionToFork }) {
if (!this.forkWorkflowEnabled) {
throw new Error('Cannot authenticate with fork; forking workflow is turned off.');
}
const { token } = userData;
// Origin maintainers should be able to use the CMS normally
if (await this.userIsOriginMaintainer({ token })) {
this.repo = this.originRepo;
this.useForkWorkflow = false;
return Promise.resolve();
}
await getPermissionToFork();
const fork = await fetch(`${this.api_root}/repos/${this.originRepo}/forks`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
}).then(res => res.json());
this.useForkWorkflow = true;
this.repo = fork.full_name;
return this.pollUntilForkExists({ repo: fork.full_name, token });
}
async authenticate(state) {
@ -70,8 +159,10 @@ export default class GitHub {
token: this.token,
branch: this.branch,
repo: this.repo,
originRepo: this.useForkWorkflow ? this.originRepo : undefined,
api_root: this.api_root,
squash_merges: this.squash_merges,
useForkWorkflow: this.useForkWorkflow,
initialWorkflowStatus: this.options.initialWorkflowStatus,
});
const user = await this.api.user();
@ -95,7 +186,7 @@ export default class GitHub {
}
// Authorized user
return { ...user, token: state.token };
return { ...user, token: state.token, useForkWorkflow: this.useForkWorkflow };
}
logout() {
@ -107,22 +198,23 @@ export default class GitHub {
return Promise.resolve(this.token);
}
entriesByFolder(collection, extension) {
return this.api
.listFiles(collection.get('folder'))
.then(files => files.filter(file => file.name.endsWith('.' + extension)))
.then(this.fetchFiles);
async entriesByFolder(collection, extension) {
const repoURL = `/repos/${this.useForkWorkflow ? this.originRepo : this.repo}`;
const files = await this.api.listFiles(collection.get('folder'));
const filteredFiles = files.filter(file => file.name.endsWith('.' + extension));
return this.fetchFiles(filteredFiles, { repoURL });
}
entriesByFiles(collection) {
const repoURL = `/repos/${this.useForkWorkflow ? this.originRepo : this.repo}`;
const files = collection.get('files').map(collectionFile => ({
path: collectionFile.get('file'),
label: collectionFile.get('label'),
}));
return this.fetchFiles(files);
return this.fetchFiles(files, { repoURL });
}
fetchFiles = files => {
fetchFiles = (files, { repoURL = `/repos/${this.repo}` } = {}) => {
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
const promises = [];
files.forEach(file => {
@ -130,7 +222,7 @@ export default class GitHub {
new Promise(resolve =>
sem.take(() =>
this.api
.readFile(file.path, file.sha)
.readFile(file.path, file.sha, { repoURL })
.then(data => {
resolve({ file, data });
sem.leave();
@ -151,7 +243,8 @@ export default class GitHub {
// Fetches a single entry.
getEntry(collection, slug, path) {
return this.api.readFile(path).then(data => ({
const repoURL = `/repos/${this.useForkWorkflow ? this.originRepo : this.repo}`;
return this.api.readFile(path, null, { repoURL }).then(data => ({
file: { path },
data,
}));
@ -202,13 +295,14 @@ export default class GitHub {
.then(branches => {
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
const promises = [];
branches.map(branch => {
branches.map(({ ref }) => {
promises.push(
new Promise(resolve => {
const slug = branch.ref.split('refs/heads/cms/').pop();
const contentKey = ref.split('refs/heads/cms/').pop();
const slug = contentKey.split('/').pop();
return sem.take(() =>
this.api
.readUnpublishedBranchFile(slug)
.readUnpublishedBranchFile(contentKey)
.then(data => {
if (data === null || data === undefined) {
resolve(null);
@ -239,12 +333,13 @@ export default class GitHub {
if (error.message === 'Not Found') {
return Promise.resolve([]);
}
return error;
return Promise.reject(error);
});
}
unpublishedEntry(collection, slug) {
return this.api.readUnpublishedBranchFile(slug).then(data => {
const contentKey = this.api.generateContentKey(collection.get('name'), slug);
return this.api.readUnpublishedBranchFile(contentKey).then(data => {
if (!data) return null;
return {
slug,
@ -263,9 +358,10 @@ export default class GitHub {
* 'pending', and 'failure'.
*/
async getDeployPreview(collection, slug) {
const data = await this.api.retrieveMetadata(slug);
const contentKey = this.api.generateContentKey(collection.get('name'), slug);
const data = await this.api.retrieveMetadata(contentKey);
if (!data) {
if (!data || !data.pr) {
return null;
}

View File

@ -7,6 +7,7 @@ export const AUTH_REQUEST = 'AUTH_REQUEST';
export const AUTH_SUCCESS = 'AUTH_SUCCESS';
export const AUTH_FAILURE = 'AUTH_FAILURE';
export const AUTH_REQUEST_DONE = 'AUTH_REQUEST_DONE';
export const USE_FORK_WORKFLOW = 'USE_FORK_WORKFLOW';
export const LOGOUT = 'LOGOUT';
export function authenticating() {
@ -36,6 +37,12 @@ export function doneAuthenticating() {
};
}
export function useForkWorkflow() {
return {
type: USE_FORK_WORKFLOW,
};
}
export function logout() {
return {
type: LOGOUT,
@ -52,6 +59,9 @@ export function authenticateUser() {
.currentUser()
.then(user => {
if (user) {
if (user.useForkWorkflow) {
dispatch(useForkWorkflow());
}
dispatch(authenticate(user));
} else {
dispatch(doneAuthenticating());
@ -73,6 +83,9 @@ export function loginUser(credentials) {
return backend
.authenticate(credentials)
.then(user => {
if (user.useForkWorkflow) {
dispatch(useForkWorkflow());
}
dispatch(authenticate(user));
})
.catch(error => {

View File

@ -250,25 +250,21 @@ export function loadMediaDisplayURL(file) {
) {
return Promise.resolve();
}
if (typeof url === 'string') {
if (typeof url === 'string' || typeof displayURL === 'string') {
dispatch(mediaDisplayURLRequest(id));
return dispatch(mediaDisplayURLSuccess(id, displayURL));
}
if (typeof displayURL === 'string') {
dispatch(mediaDisplayURLRequest(id));
return dispatch(mediaDisplayURLSuccess(id, displayURL));
dispatch(mediaDisplayURLSuccess(id, displayURL));
}
try {
const backend = currentBackend(state.config);
dispatch(mediaDisplayURLRequest(id));
const newURL = await backend.getMediaDisplayURL(displayURL);
if (newURL) {
return dispatch(mediaDisplayURLSuccess(id, newURL));
dispatch(mediaDisplayURLSuccess(id, newURL));
} else {
throw new Error('No display URL was returned!');
}
} catch (err) {
return dispatch(mediaDisplayURLFailure(id, err));
dispatch(mediaDisplayURLFailure(id, err));
}
};
}

View File

@ -61,6 +61,7 @@ class Editor extends React.Component {
newEntry: PropTypes.bool.isRequired,
displayUrl: PropTypes.string,
hasWorkflow: PropTypes.bool,
useForkWorkflow: PropTypes.bool,
unpublishedEntry: PropTypes.bool,
isModification: PropTypes.bool,
collectionEntriesLoaded: PropTypes.bool,
@ -350,6 +351,7 @@ class Editor extends React.Component {
hasChanged,
displayUrl,
hasWorkflow,
useForkWorkflow,
unpublishedEntry,
newEntry,
isModification,
@ -397,6 +399,7 @@ class Editor extends React.Component {
hasChanged={hasChanged}
displayUrl={displayUrl}
hasWorkflow={hasWorkflow}
useForkWorkflow={useForkWorkflow}
hasUnpublishedChanges={unpublishedEntry}
isNewEntry={newEntry}
isModification={isModification}
@ -410,7 +413,7 @@ class Editor extends React.Component {
}
function mapStateToProps(state, ownProps) {
const { collections, entryDraft, auth, config, entries } = state;
const { collections, entryDraft, auth, config, entries, globalUI } = state;
const slug = ownProps.match.params.slug;
const collection = collections.get(ownProps.match.params.name);
const collectionName = collection.get('name');
@ -422,6 +425,7 @@ function mapStateToProps(state, ownProps) {
const hasChanged = entryDraft.get('hasChanged');
const displayUrl = config.get('display_url');
const hasWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
const useForkWorkflow = globalUI.get('useForkWorkflow', false);
const isModification = entryDraft.getIn(['entry', 'isModification']);
const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]);
const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
@ -441,6 +445,7 @@ function mapStateToProps(state, ownProps) {
hasChanged,
displayUrl,
hasWorkflow,
useForkWorkflow,
isModification,
collectionEntriesLoaded,
currentStatus,

View File

@ -166,6 +166,7 @@ class EditorInterface extends Component {
hasChanged,
displayUrl,
hasWorkflow,
useForkWorkflow,
hasUnpublishedChanges,
isNewEntry,
isModification,
@ -240,6 +241,7 @@ class EditorInterface extends Component {
displayUrl={displayUrl}
collection={collection}
hasWorkflow={hasWorkflow}
useForkWorkflow={useForkWorkflow}
hasUnpublishedChanges={hasUnpublishedChanges}
isNewEntry={isNewEntry}
isModification={isModification}
@ -293,6 +295,7 @@ EditorInterface.propTypes = {
hasChanged: PropTypes.bool,
displayUrl: PropTypes.string,
hasWorkflow: PropTypes.bool,
useForkWorkflow: PropTypes.bool,
hasUnpublishedChanges: PropTypes.bool,
isNewEntry: PropTypes.bool,
isModification: PropTypes.bool,

View File

@ -218,6 +218,7 @@ class EditorToolbar extends React.Component {
displayUrl: PropTypes.string,
collection: ImmutablePropTypes.map.isRequired,
hasWorkflow: PropTypes.bool,
useForkWorkflow: PropTypes.bool,
hasUnpublishedChanges: PropTypes.bool,
isNewEntry: PropTypes.bool,
isModification: PropTypes.bool,
@ -379,6 +380,7 @@ class EditorToolbar extends React.Component {
onPublishAndNew,
currentStatus,
isNewEntry,
useForkWorkflow,
t,
} = this.props;
if (currentStatus) {
@ -406,37 +408,45 @@ class EditorToolbar extends React.Component {
onClick={() => onChangeStatus('PENDING_REVIEW')}
icon={currentStatus === status.get('PENDING_REVIEW') && 'check'}
/>
<StatusDropdownItem
label={t('editor.editorToolbar.ready')}
onClick={() => onChangeStatus('PENDING_PUBLISH')}
icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'}
/>
</ToolbarDropdown>
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<PublishButton>
{isPublishing
? t('editor.editorToolbar.publishing')
: t('editor.editorToolbar.publish')}
</PublishButton>
)}
>
<DropdownItem
label={t('editor.editorToolbar.publishNow')}
icon="arrow"
iconDirection="right"
onClick={onPublish}
/>
{collection.get('create') ? (
<DropdownItem
label={t('editor.editorToolbar.publishAndCreateNew')}
icon="add"
onClick={onPublishAndNew}
{useForkWorkflow ? (
''
) : (
<StatusDropdownItem
label={t('editor.editorToolbar.ready')}
onClick={() => onChangeStatus('PENDING_PUBLISH')}
icon={currentStatus === status.get('PENDING_PUBLISH') && 'check'}
/>
) : null}
)}
</ToolbarDropdown>
{useForkWorkflow ? (
''
) : (
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="150px"
renderButton={() => (
<PublishButton>
{isPublishing
? t('editor.editorToolbar.publishing')
: t('editor.editorToolbar.publish')}
</PublishButton>
)}
>
<DropdownItem
label={t('editor.editorToolbar.publishNow')}
icon="arrow"
iconDirection="right"
onClick={onPublish}
/>
{collection.get('create') ? (
<DropdownItem
label={t('editor.editorToolbar.publishAndCreateNew')}
icon="add"
onClick={onPublishAndNew}
/>
) : null}
</ToolbarDropdown>
)}
</>
);
}

View File

@ -55,6 +55,7 @@ class Workflow extends Component {
static propTypes = {
collections: ImmutablePropTypes.orderedMap,
isEditorialWorkflow: PropTypes.bool.isRequired,
isForkWorkflow: PropTypes.bool,
isFetching: PropTypes.bool,
unpublishedEntries: ImmutablePropTypes.map,
loadUnpublishedEntries: PropTypes.func.isRequired,
@ -74,6 +75,7 @@ class Workflow extends Component {
render() {
const {
isEditorialWorkflow,
isForkWorkflow,
isFetching,
unpublishedEntries,
updateUnpublishedEntryStatus,
@ -125,6 +127,7 @@ class Workflow extends Component {
handleChangeStatus={updateUnpublishedEntryStatus}
handlePublish={publishUnpublishedEntry}
handleDelete={deleteUnpublishedEntry}
isForkWorkflow={isForkWorkflow}
/>
</WorkflowContainer>
);
@ -132,9 +135,10 @@ class Workflow extends Component {
}
function mapStateToProps(state) {
const { collections } = state;
const isEditorialWorkflow = state.config.get('publish_mode') === EDITORIAL_WORKFLOW;
const returnObj = { collections, isEditorialWorkflow };
const { collections, config, globalUI } = state;
const isEditorialWorkflow = config.get('publish_mode') === EDITORIAL_WORKFLOW;
const isForkWorkflow = globalUI.get('useForkWorkflow', false);
const returnObj = { collections, isEditorialWorkflow, isForkWorkflow };
if (isEditorialWorkflow) {
returnObj.isFetching = state.editorialWorkflow.getIn(['pages', 'isFetching'], false);

View File

@ -17,6 +17,12 @@ const WorkflowListContainer = styled.div`
grid-template-columns: 33.3% 33.3% 33.3%;
`;
const WorkflowListContainerForkWorkflow = styled.div`
min-height: 60%;
display: grid;
grid-template-columns: 50% 50% 0%;
`;
const styles = {
columnPosition: idx =>
(idx === 0 &&
@ -58,6 +64,16 @@ const styles = {
columnHovered: css`
border-color: ${colors.active};
`,
hiddenColumn: css`
display: none;
`,
hiddenRightBorder: css`
&:not(:first-child):not(:last-child) {
&:after {
display: none;
}
}
`,
};
const ColumnHeader = styled.h2`
@ -118,6 +134,7 @@ class WorkflowList extends React.Component {
handlePublish: PropTypes.func.isRequired,
handleDelete: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
isForkWorkflow: PropTypes.bool,
};
handleChangeStatus = (newStatus, dragProps) => {
@ -145,6 +162,7 @@ class WorkflowList extends React.Component {
// eslint-disable-next-line react/display-name
renderColumns = (entries, column) => {
const { isForkWorkflow } = this.props;
if (!entries) return null;
if (!column) {
@ -162,6 +180,8 @@ class WorkflowList extends React.Component {
styles.column,
styles.columnPosition(idx),
isHovered && styles.columnHovered,
isForkWorkflow && currColumn === 'pending_publish' && styles.hiddenColumn,
isForkWorkflow && currColumn === 'pending_review' && styles.hiddenRightBorder,
]}
>
<ColumnHeader name={currColumn}>
@ -228,7 +248,10 @@ class WorkflowList extends React.Component {
render() {
const columns = this.renderColumns(this.props.entries);
return <WorkflowListContainer>{columns}</WorkflowListContainer>;
const ListContainer = this.props.isForkWorkflow
? WorkflowListContainerForkWorkflow
: WorkflowListContainer;
return <ListContainer>{columns}</ListContainer>;
}
}

View File

@ -1,13 +1,16 @@
import { Map } from 'immutable';
import { USE_FORK_WORKFLOW } from 'Actions/auth';
/*
* Reducer for some global UI state that we want to share between components
* */
const globalUI = (state = Map({ isFetching: false }), action) => {
const globalUI = (state = Map({ isFetching: false, useForkWorkflow: false }), action) => {
// Generic, global loading indicator
if (action.type.indexOf('REQUEST') > -1) {
return state.set('isFetching', true);
} else if (action.type.indexOf('SUCCESS') > -1 || action.type.indexOf('FAILURE') > -1) {
return state.set('isFetching', false);
} else if (action.type === USE_FORK_WORKFLOW) {
return state.set('useForkWorkflow', true);
}
return state;
};

View File

@ -3,7 +3,13 @@ import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from './Cursor';
import EditorialWorkflowError, { EDITORIAL_WORKFLOW_ERROR } from './EditorialWorkflowError';
import localForage from './localForage';
import { resolvePath, basename, fileExtensionWithSeparator, fileExtension } from './path';
import { filterPromises, resolvePromiseProperties, then } from './promise';
import {
filterPromises,
filterPromisesWith,
onlySuccessfulPromises,
resolvePromiseProperties,
then,
} from './promise';
import unsentRequest from './unsentRequest';
import { filterByPropExtension, parseResponse, responseParser } from './backendUtil';
import loadScript from './loadScript';
@ -21,6 +27,8 @@ export const NetlifyCmsLibUtil = {
fileExtensionWithSeparator,
fileExtension,
filterPromises,
filterPromisesWith,
onlySuccessfulPromises,
resolvePromiseProperties,
then,
unsentRequest,
@ -42,6 +50,8 @@ export {
fileExtensionWithSeparator,
fileExtension,
filterPromises,
filterPromisesWith,
onlySuccessfulPromises,
resolvePromiseProperties,
then,
unsentRequest,

View File

@ -1,7 +1,15 @@
import constant from 'lodash/constant';
import filter from 'lodash/fp/filter';
import map from 'lodash/fp/map';
import flow from 'lodash/flow';
import zipObject from 'lodash/zipObject';
export const filterPromises = (arr, filter) =>
Promise.all(arr.map(entry => filter(entry))).then(bits => arr.filter(() => bits.shift()));
Promise.all(arr.map(entry => Promise.resolve(entry).then(filter))).then(bits =>
arr.filter(() => bits.shift()),
);
export const filterPromisesWith = filter => arr => filterPromises(arr, filter);
export const resolvePromiseProperties = obj => {
// Get the keys which represent promises
@ -18,3 +26,10 @@ export const resolvePromiseProperties = obj => {
};
export const then = fn => p => Promise.resolve(p).then(fn);
const filterPromiseSymbol = Symbol('filterPromiseSymbol');
export const onlySuccessfulPromises = flow([
then(map(p => p.catch(constant(filterPromiseSymbol)))),
then(Promise.all.bind(Promise)),
then(filter(maybeValue => maybeValue !== filterPromiseSymbol)),
]);

View File

@ -72,7 +72,7 @@ const AuthenticationPage = ({
<StyledAuthenticationPage>
{renderPageLogo(logoUrl)}
{loginErrorMessage ? <p>{loginErrorMessage}</p> : null}
{!renderPageContent ? null : renderPageContent()}
{!renderPageContent ? null : renderPageContent({ LoginButton })}
{!renderButtonContent ? null : (
<LoginButton disabled={loginDisabled} onClick={onLogin}>
{renderButtonContent()}

View File

@ -227,3 +227,11 @@ Template tags produce the following output:
- `{{collection}}`: the name of the collection containing the entry changed
- `{{path}}`: the full path to the file changed
### Open Authoring
When using the [GitHub backend](/docs/authentication-backends/#github-backend), you can use Netlify CMS to accept contributions from GitHub users without giving them access to your repository. When they make changes in the CMS, the CMS forks your repository for them behind the scenes, and all the changes are made to the fork. When the contributor is ready to submit their changes, they can set their draft as ready for review in the CMS. This triggers a pull request to your repository, which you can merge using the GitHub UI.
At the same time, any contributors who _do_ have write access to the repository can continue to use Netlify CMS normally.
More details and setup instructions can be found on [the Open Authoring docs page](/docs/open-authoring).

View File

@ -0,0 +1,49 @@
---
title: Open Authoring
group: features
---
**This is a [beta feature](/docs/beta-features#open-authoring).**
When using the [GitHub backend](/docs/authentication-backends/#github-backend), you can use Netlify CMS to accept contributions from GitHub users without giving them access to your repository. When they make changes in the CMS, the CMS forks your repository for them behind the scenes, and all the changes are made to the fork. When the contributor is ready to submit their changes, they can set their draft as ready for review in the CMS. This triggers a pull request to your repository, which you can merge using the GitHub UI.
At the same time, any contributors who _do_ have write access to the repository can continue to use Netlify CMS normally.
## Requirements
- You must use [the GitHub backend](/docs/authentication-backends/#github-backend).
**Note that the [Git Gateway backend](/docs/authentication-backends/#git-gateway-with-netlify-identity) does _not_ support Open Authoring, even when the underlying repo is on GitHub.**
- Your repo on GitHub must be public.
## Enabling the Open Authoring
1. [Enable the editorial workflow](/docs/configuration-options/#publish-mode) by setting `publish_mode` to `editorial_workflow` in your `config.yml`.
2. Set `fork_workflow` to `true` in the `backend` section of your `config.yml`, as follows:
```yaml
backend:
name: github
repo: owner-name/repo-name # Path to your GitHub repository
fork_workflow: true
```
## Usage
When a user logs into Netlify CMS who doesn't have write access to your repo, the CMS asks for permission to create a fork of your repo (or uses their existing fork, if they already have one). They are then presented with the normal CMS interface. The published content shown is from the original repo, so it stays up-to-date as changes are made.
On the editorial workflow screen, the normal three columns are replaced by two columns instead — "Draft" and "Ready to Review".
When they make changes to content in the CMS, the changes are made to a branch on their fork. In the editorial workflow screen, they see only their own pending changes. Once they're ready to submit their changes, they can move the card into the "Ready To Review" column to create a pull request. When the pull request is merged (by a repository maintainer via the GitHub UI), Netlify CMS deletes the branch and removes the card from the user's editorial workflow screen. Open Authoring users cannot publish entries through the CMS.
Users who _do_ have write access to the original repository continue to use the CMS normally. Unpublished changes made by users via Open Authoring are not visible on the editorial workflow screen, and their unpublished changes must be merged through the GitHub UI.
## Alternative for external contributors with Git Gateway
[As noted above](#requirements), Open Authoring does not work with the Git Gateway backend. However, you can use Git Gateway on a site with Netlify Identity that has [open registration](https://www.netlify.com/docs/identity/#adding-identity-users). This lets users create accounts on your site and log into the CMS. There are a few differences, including the following:
- Users don't need to know about GitHub or create a GitHub account. Instead, they use Netlify Identity accounts that are created on your site and managed by you.
- The CMS applies users' changes directly to your repo, not to a fork. (If you use the editorial workflow, you can use features like [GitHub's protected branches](https://help.github.com/en/articles/about-protected-branches) or [Netlify's locked deploys](https://www.netlify.com/docs/locked-deploys/) to prevent users from publishing directly to your site from the CMS.)
- There is no distinction between users with write access to the repo and users without — all editorial workflow entries are visible from within the CMS and can be published with the CMS. (Unpublished Open Authoring entries, on the other hand, are visible only to the author in the CMS UI or publicly as GitHub PRs.)