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
21 changes: 18 additions & 3 deletions .buildkite/commands/setup_macos_code_signing.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,23 @@ bundle exec fastlane setup_code_signing
echo "Expose signing config to electron-builder..."
# Export necessary env vars for `electron-builder` to find those for its `notarize` option
# See https://www.electron.build/mac#notarize
mkdir -p .codesigning/
echo "$APP_STORE_CONNECT_API_KEY_KEY" >.codesigning/apple_api_key
export APPLE_API_KEY=".codesigning/apple_api_key"
#
# The Buildkite step sources this script, so the shebang's `-eu` never applies. Return explicitly
# so callers without `errexit` still receive materialization failures instead of continuing.
MACOS_NOTARIZATION_TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/wordpress-contributor-toolkit-signing.XXXXXX")" || return 1
export APPLE_API_KEY="$MACOS_NOTARIZATION_TEMP_DIR/apple_api_key"

cleanup_macos_notarization_key() {
rm -rf "$MACOS_NOTARIZATION_TEMP_DIR"
}
trap cleanup_macos_notarization_key EXIT

# `printenv` keeps the key itself out of shell traces, while the private temporary directory and
# restrictive file mode keep it unavailable to other users on the build agent. Unlike `echo` under
# `set -u`, `printenv` exits quietly when the variable is missing — hence the explicit guard.
( umask 077; printenv APP_STORE_CONNECT_API_KEY_KEY >"$APPLE_API_KEY" ) || {
echo "APP_STORE_CONNECT_API_KEY_KEY is unset or could not be written to $APPLE_API_KEY" >&2
return 1
}
export APPLE_API_KEY_ID="$APP_STORE_CONNECT_API_KEY_KEY_ID"
export APPLE_API_ISSUER="$APP_STORE_CONNECT_API_KEY_ISSUER_ID"
7 changes: 4 additions & 3 deletions docs/guide/creating-a-site.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ You do not need one, though, for every ticket you work on. A site holds as many

When the app starts with no sites, the main area shows a short prompt to create your first one.

![The app before any site exists: an empty main area and the Create WordPress Core site button at the bottom of the sidebar](/screenshots/empty-state.png)
![The app before any site exists: an empty main area and the Create a contributor site button at the bottom of the sidebar](/screenshots/empty-state.png)

## Start the creation flow

Click **Create WordPress Core site** at the bottom of the sidebar. A dialog opens with two fields.
Click **Create a contributor site** at the bottom of the sidebar. A dialog opens.

![The Create WordPress Core site dialog, with a Site name text field and a Site location folder picker](/screenshots/create-site-modal.png)
![The Create a contributor site dialog, with a Contribute to choice, a Site name text field and a Site location folder picker](/screenshots/create-site-modal.png)

- **Contribute to** — the project this site targets: **WordPress Core** (Trac tickets) or **Gutenberg** (GitHub issues). This sets which repository is cloned and where its pull requests go, and cannot be changed later. WordPress Core is selected by default.
- **Site name** — the label shown in the sidebar. It also determines the folder name: spaces and characters that are not valid in file names become hyphens, so a site named `My WordPress site` lives in a folder called `My-WordPress-site`.
- **Site location** — the parent folder where the site will be created. The app adds a new directory inside it for the project; it does not clone into the folder you pick directly.

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The app is signed and notarized by Automattic, so macOS should open it without i

## Your first contribution, in ten steps

1. Click **Create WordPress Core site** and choose a destination folder for your site.
1. Click **Create a contributor site**, leave **Contribute to** on WordPress Core, and choose a destination folder for your site.
2. Wait while the app downloads `wordpress-develop`.
3. Click **Install npm dependencies**.
4. Click **Run full build**.
Expand Down
Binary file modified docs/public/screenshots/create-site-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/public/screenshots/empty-state.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"buildResources": "build"
},
"files": [
"!vendor{,/**/*}"
"!vendor{,/**/*}",
"!.codesigning{,/**/*}"
],
"icon": "build/icon.png",
"linux": {
Expand Down
2 changes: 1 addition & 1 deletion scripts/screenshots/shots.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const shots = [
tier: 'fixture',
variant: 'empty',
prepare: async (page) => {
await page.getByRole('button', { name: 'Create WordPress Core site' }).click();
await page.getByRole('button', { name: 'Create a contributor site' }).click();
await page.getByRole('dialog').getByText('Site name').waitFor();
}
},
Expand Down
41 changes: 29 additions & 12 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const {
} = require('./ticket-branches');
const { createProgressThrottle, describeSwitchProgress } = require('./switch-progress.cjs');
const { getStore } = require('./settings-store');
const { normalizeProjectType, getProjectType, projectTypeForSite } = require('./project-type.cjs');

// One name for the send-only progress channel (#173), shared with preload.js
// through the tests rather than by import — the renderer bundle and the main
Expand Down Expand Up @@ -93,7 +94,6 @@ if (!app.isPackaged && process.env.TOOLKIT_USER_DATA_DIR) {
}
}

const WORDPRESS_GIT_URL = 'https://github.com/WordPress/wordpress-develop.git';

// Provide a PATH shim so npm's spawned scripts can find a 'node' binary that maps to Electron's Node
let nodeShimDir = null;
Expand Down Expand Up @@ -1227,7 +1227,11 @@ ipcMain.handle('git:update-trunk', async (event, sitePath) => {
await mergeSiteMeta(sitePath, { currentBranch: TRUNK });
}

const result = await updateToLatestTrunk({ dir: sitePath, url: WORDPRESS_GIT_URL, onLog: sendLog });
// Pull from the site's own upstream, not always wordpress-develop
// (#251): updating a Gutenberg checkout from Core's trunk would
// overwrite it with a different project entirely. Defaults to Core.
const updateUrl = projectTypeForSite(await readSiteMeta(sitePath)).clone.url;
const result = await updateToLatestTrunk({ dir: sitePath, url: updateUrl, onLog: sendLog });
// An update resets the worktree, so any applied patch is gone with
// it either way — clear the record so the "applied" banner does not
// outlive the patch. (This is also where a discard's cleanup lands:
Expand Down Expand Up @@ -1588,13 +1592,16 @@ ipcMain.handle('site:status', async (_e, sitePath) => {
const nmDir = path.join(sitePath, 'node_modules');
const hasNodeModules = fs.existsSync(nmDir) && (() => { try { return fs.readdirSync(nmDir).length > 0; } catch { return false; } })();

const distDir = path.join(sitePath, 'build', 'wp-includes', 'js', 'dist');
const hasBuilt = fs.existsSync(distDir);

const s = await getStore();
const meta = s.get('siteMeta') || {};
const m = meta[sitePath] || {};

// "Is this site built?" is answered differently per project (#251): Core
// has a built wp-includes dist dir, Gutenberg builds into build/<package>.
// The marker path comes from the site's project type, defaulting to Core.
const builtDir = path.join(sitePath, ...projectTypeForSite(m).build.builtCheckRelPath);
const hasBuilt = fs.existsSync(builtDir);

// Trunk snapshot age (#94). Read from HEAD each time (one object
// read) and written through to siteMeta, so the sidebar can render
// staleness dots from siteMeta alone, without per-site git I/O.
Expand Down Expand Up @@ -1626,9 +1633,9 @@ ipcMain.handle('site:status', async (_e, sitePath) => {
}
: null;

return { hasNodeModules, hasBuilt, skipInitWizard: Boolean(m.skipInitWizard), initialized: Boolean(m.initialized), installFailed: Boolean(m.installFailed), trunkOid, trunkDate, updateIncomplete: Boolean(work.updateIncomplete), tracTicket: m.tracTicket || null, appliedPatch };
return { hasNodeModules, hasBuilt, skipInitWizard: Boolean(m.skipInitWizard), initialized: Boolean(m.initialized), installFailed: Boolean(m.installFailed), trunkOid, trunkDate, updateIncomplete: Boolean(work.updateIncomplete), tracTicket: m.tracTicket || null, projectType: m.projectType || 'core', appliedPatch };
} catch {
return { hasNodeModules: false, hasBuilt: false, skipInitWizard: false, initialized: false, installFailed: false, trunkOid: null, trunkDate: null, updateIncomplete: false, tracTicket: null, appliedPatch: null };
return { hasNodeModules: false, hasBuilt: false, skipInitWizard: false, initialized: false, installFailed: false, trunkOid: null, trunkDate: null, updateIncomplete: false, tracTicket: null, projectType: 'core', appliedPatch: null };
}
});

Expand Down Expand Up @@ -1675,6 +1682,11 @@ ipcMain.handle('wordpress:setup', async (event, destDir, options = {}) => {

await fse.ensureDir(destDir);

// The contribution target for this site (#251), resolved once: it drives both
// the clone below and the metadata written after it, so they cannot disagree.
const projectType = normalizeProjectType(options.projectType);
const projectConfig = getProjectType(projectType);

const requestedName = typeof options.siteName === 'string' ? options.siteName.trim() : '';
const sanitizedName = requestedName.replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '-').replace(/^-+|-+$/g, '') || 'wordpress-develop-trunk';
const uniqueName = findAvailableDirName(destDir, sanitizedName);
Expand All @@ -1692,11 +1704,11 @@ ipcMain.handle('wordpress:setup', async (event, destDir, options = {}) => {
await git.clone({
http,
fs,
url: WORDPRESS_GIT_URL,
url: projectConfig.clone.url,
dir: siteDir,
singleBranch: true,
depth: 1,
ref: 'trunk',
singleBranch: projectConfig.clone.singleBranch,
depth: projectConfig.clone.depth,
ref: projectConfig.clone.ref,
onProgress: (evt) => {
// evt: {phase,total,loaded,lengthComputable} - forward as terminal-like output
const msg = `${evt.phase || 'clone'} ${evt.loaded || 0}/${evt.total || 0}`;
Expand All @@ -1719,7 +1731,12 @@ ipcMain.handle('wordpress:setup', async (event, destDir, options = {}) => {
...existingMeta,
initialized: false,
createdAt: existingMeta.createdAt || new Date().toISOString(),
label: existingMeta.label || siteLabel
label: existingMeta.label || siteLabel,
// The contribution target chosen in the wizard (#251), normalized to a
// known id so an unknown value is stored as Core rather than left to
// coerce on every read. Preserved on re-setup of a folder, and the
// same id the clone above used.
projectType: existingMeta.projectType || projectType
};
try {
const { trunkOid, trunkDate } = await readTrunkInfo(siteDir);
Expand Down
138 changes: 138 additions & 0 deletions src/project-type.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
'use strict';

// The contribution targets ("project types") the toolkit can host.
//
// Until now every site was implicitly a `wordpress-develop` (WordPress Core)
// checkout, and that assumption was a scattering of constants: the clone URL,
// the "is it built?" path, the dev/build scripts, the Playground serve model,
// the work-item source (Trac), and the pull-request upstream. This module is
// the one place those per-target facts live, so a site's chosen type — not a
// constant buried in a handler — drives each of them (issue #251).
//
// It is deliberately pure data + pure functions: no `electron`, no `fs`, no
// side effects. Both the main process and the renderer bundle `require`/`import`
// it, and `node --test` loads it directly.
//
// The invariant every consumer relies on: an unknown or missing type resolves
// to Core. A site created before this field existed has no `projectType`, so it
// keeps Core behavior with no migration and no store write.

const WORDPRESS_DEVELOP_GIT_URL = 'https://github.com/WordPress/wordpress-develop.git';
const GUTENBERG_GIT_URL = 'https://github.com/WordPress/gutenberg.git';

const DEFAULT_PROJECT_TYPE = 'core';

const PROJECT_TYPES = {
core: {
id: 'core',
label: 'WordPress Core',
// The option label shown in the create-site wizard picker.
wizardLabel: 'WordPress Core (Trac tickets)',
// One-line description shown next to the choice in the create-site wizard.
description: 'Contribute to WordPress Core using Trac tickets.',
// The noun this target uses for a unit of work, for UI copy: a Core site
// links a "ticket", a Gutenberg site links an "issue".
workItemNoun: 'ticket',

clone: { url: WORDPRESS_DEVELOP_GIT_URL, ref: 'trunk', singleBranch: true, depth: 1 },
upstream: { owner: 'WordPress', repo: 'wordpress-develop', base: 'trunk' },

build: {
// site:status checks this path under the site to decide "is it built?".
builtCheckRelPath: ['build', 'wp-includes', 'js', 'dist'],
buildScript: 'build',
// wordpress-develop's dev watcher is Grunt, reached through npm's `--`
// passthrough (see dev-server-command.cjs for why the separator matters).
watch: { script: 'grunt', args: ['--', '_watch'], label: 'npm run grunt -- _watch' },
allowedScripts: ['build', 'build:dev', 'dev', 'test', 'watch', 'grunt']
},

// 'docroot' — the built checkout IS the WordPress install Playground serves.
serve: { strategy: 'docroot' },

// 'src-layout' — patch paths are rewritten into wordpress-develop's
// src/wp-includes layout (patch-plan.cjs mapToSrcLayout).
patch: { layout: 'src-layout' },

workItem: { provider: 'trac' },

pr: {
branchPrefix: 'trac-',
// The line that ties the pull request back to its work item.
bodyLine: (id, url) => `Trac ticket: ${url}`,
closesKeyword: null
}
},

gutenberg: {
id: 'gutenberg',
label: 'Gutenberg',
wizardLabel: 'Gutenberg (GitHub issues)',
description: 'Contribute to the block editor (Gutenberg) using GitHub issues.',
workItemNoun: 'issue',

clone: { url: GUTENBERG_GIT_URL, ref: 'trunk', singleBranch: true, depth: 1 },
upstream: { owner: 'WordPress', repo: 'gutenberg', base: 'trunk' },

build: {
// Gutenberg builds each package into build/<package>; block-library is
// always present in a completed build, so its directory is the marker.
builtCheckRelPath: ['build', 'block-library'],
buildScript: 'build',
// `npm run dev` is already an incremental watcher — it must NOT inherit
// Core's `grunt -- _watch` passthrough dance.
watch: { script: 'dev', args: [], label: 'npm run dev' },
allowedScripts: ['build', 'dev', 'test', 'test:unit', 'lint']
},

// 'plugin-mount' — Gutenberg is a plugin, so Playground boots a stock
// WordPress and mounts the built checkout as an active plugin.
serve: { strategy: 'plugin-mount' },

// 'repo-relative' — Gutenberg PR diffs are already repo-relative
// (packages/…); no src-layout rewrite.
patch: { layout: 'repo-relative' },

workItem: { provider: 'github-issue' },

pr: {
branchPrefix: 'fix/issue-',
bodyLine: (id) => `Fixes #${id}`,
closesKeyword: 'Fixes'
}
}
};

// Resolve a stored id to its config, defaulting to Core for anything unknown or
// missing. This is the single seam every caller uses — never index
// PROJECT_TYPES directly with untrusted input.
function getProjectType(id) {
return PROJECT_TYPES[id] || PROJECT_TYPES[DEFAULT_PROJECT_TYPE];
}

// Convenience for the common case: resolve straight from a site's stored meta.
function projectTypeForSite(meta) {
return getProjectType(meta && meta.projectType);
}

// True only for an id the registry actually defines — for validating input
// before persisting it (an unknown id is coerced to Core on read, but we store
// the normalized id, not the raw input).
function isProjectTypeId(id) {
return Object.prototype.hasOwnProperty.call(PROJECT_TYPES, id);
}

// Normalize arbitrary input to a stored id: a known id passes through, anything
// else becomes the default. Used at the write boundary (wordpress:setup).
function normalizeProjectType(id) {
return isProjectTypeId(id) ? id : DEFAULT_PROJECT_TYPE;
}

module.exports = {
PROJECT_TYPES,
DEFAULT_PROJECT_TYPE,
getProjectType,
projectTypeForSite,
isProjectTypeId,
normalizeProjectType
};
37 changes: 23 additions & 14 deletions src/renderer/dev-server-command.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,46 @@
* DOM: the renderer bundle imports it, `node --test` requires it directly
* (same convention as setup-steps.cjs).
*
* Why the watcher is `grunt -- _watch` and not `npm run watch`:
* The build and watch commands are not the same for every contribution target,
* so they come from the project-type registry's `build` config rather than
* being hard-coded here. `planDevServerStart` defaults to Core's config, which
* keeps every existing caller (and its tests) unchanged.
*
* Why Core's watcher is `grunt -- _watch` and not `npm run watch`:
* wordpress-develop's Gruntfile renames the real watch task to `_watch` and
* registers a `watch` wrapper that runs the entire production `build` task
* first when invoked without arguments. On a site that has already completed
* the wizard's full build that rebuild has nothing to do, yet it is where
* tens of minutes go on every dev-server start (30+ on a Windows VM).
* Invoking `_watch` through the `grunt` passthrough script starts the same
* watchers immediately. Sites without a completed build still need one, so
* they get `npm run build` — whose exit code is a real completion signal —
* before the watcher starts.
* they get `npm run <buildScript>` — whose exit code is a real completion
* signal — before the watcher starts.
*
* The `'--'` in the watcher args is load-bearing: script-runner.js
* The `'--'` in Core's watcher args is load-bearing: script-runner.js
* deliberately does not insert a separator, and without one npm consumes
* `_watch` as its own argument and runs bare `grunt` — the default task,
* i.e. a full build with no watcher.
* i.e. a full build with no watcher. Gutenberg's watcher is `npm run dev`,
* already an incremental watcher, so it takes no such separator.
*/

const WATCH_SCRIPT = 'grunt';
const WATCH_ARGS = ['--', '_watch'];
const WATCH_COMMAND_LABEL = 'npm run grunt -- _watch';
const { getProjectType } = require('../project-type.cjs');

const CORE_BUILD_CONFIG = getProjectType('core').build;

function planDevServerStart(flags = {}) {
function planDevServerStart(flags = {}, buildConfig = CORE_BUILD_CONFIG) {
const hasBuilt = Boolean(flags.hasBuilt);
const build = buildConfig || CORE_BUILD_CONFIG;
return {
// True when `npm run build` must run (and exit 0) before the watcher
// and the server may start.
// True when the build must run (and exit 0) before the watcher and the
// server may start.
needsBuild: !hasBuilt,
// The npm script that produces a completed build for this project.
buildScript: build.buildScript,
watch: {
script: WATCH_SCRIPT,
args: WATCH_ARGS.slice(),
label: WATCH_COMMAND_LABEL
script: build.watch.script,
args: build.watch.args.slice(),
label: build.watch.label
}
};
}
Expand Down
Loading