@wpelevator/wp-release 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +316 -2
- package/dist/bin/wp-release.d.ts +2 -0
- package/dist/bin/wp-release.js +3 -0
- package/dist/checksums.d.ts +138 -0
- package/dist/checksums.js +216 -0
- package/dist/cli.d.ts +42 -0
- package/dist/cli.js +163 -0
- package/dist/commands/args.d.ts +81 -0
- package/dist/commands/args.js +116 -0
- package/dist/commands/checksums.d.ts +34 -0
- package/dist/commands/checksums.js +76 -0
- package/dist/commands/command.d.ts +63 -0
- package/dist/commands/command.js +33 -0
- package/dist/commands/deploy-svn.d.ts +74 -0
- package/dist/commands/deploy-svn.js +103 -0
- package/dist/commands/verify.d.ts +27 -0
- package/dist/commands/verify.js +60 -0
- package/dist/commands/version-check.d.ts +38 -0
- package/dist/commands/version-check.js +80 -0
- package/dist/commands/zip.d.ts +41 -0
- package/dist/commands/zip.js +138 -0
- package/dist/deploy-options.d.ts +136 -0
- package/dist/deploy-options.js +168 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +16 -0
- package/dist/file-selection.d.ts +78 -0
- package/dist/file-selection.js +133 -0
- package/dist/filename.d.ts +17 -0
- package/dist/filename.js +23 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +15 -0
- package/dist/logger.d.ts +42 -0
- package/dist/logger.js +56 -0
- package/dist/package-meta.d.ts +112 -0
- package/dist/package-meta.js +222 -0
- package/dist/svn-deploy.d.ts +208 -0
- package/dist/svn-deploy.js +485 -0
- package/dist/svn.d.ts +209 -0
- package/dist/svn.js +298 -0
- package/dist/sync.d.ts +32 -0
- package/dist/sync.js +90 -0
- package/dist/unzip.d.ts +45 -0
- package/dist/unzip.js +121 -0
- package/dist/version-check.d.ts +75 -0
- package/dist/version-check.js +193 -0
- package/dist/zip.d.ts +32 -0
- package/dist/zip.js +53 -0
- package/package.json +61 -5
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Logger } from './logger.ts';
|
|
2
|
+
import type { DeployOptions } from './deploy-options.ts';
|
|
3
|
+
import type { PackageMeta } from './package-meta.ts';
|
|
4
|
+
import { Svn } from './svn.ts';
|
|
5
|
+
import type { SvnStatusEntry } from './svn.ts';
|
|
6
|
+
/**
|
|
7
|
+
* MIME types of the plugin directory asset formats. svn guesses binary
|
|
8
|
+
* types, but WordPress.org serves assets with the svn:mime-type property,
|
|
9
|
+
* so it's set explicitly. WordPress.org documents PNG, JPEG and GIF images
|
|
10
|
+
* and SVG icons; other formats keep the type svn assigns.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ASSET_MIME_TYPES: Record<string, string>;
|
|
13
|
+
/**
|
|
14
|
+
* Result of a deploy.
|
|
15
|
+
*/
|
|
16
|
+
export interface DeployResult {
|
|
17
|
+
/** Whether an SVN commit was made. */
|
|
18
|
+
committed: boolean;
|
|
19
|
+
/** SVN revision of the commit, null when nothing was committed. */
|
|
20
|
+
revision: number | null;
|
|
21
|
+
/** Deployed version, null when it's unknown, like for assets-only runs. */
|
|
22
|
+
version: string | null;
|
|
23
|
+
/** Changed paths in the working copy, relative to the repository root. */
|
|
24
|
+
changes: SvnStatusEntry[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Working copy path to update after the sparse checkout.
|
|
28
|
+
*/
|
|
29
|
+
export interface CheckoutPath {
|
|
30
|
+
path: string;
|
|
31
|
+
depth: 'immediates' | 'infinity';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Deploy a plugin to the WordPress.org plugin SVN repository.
|
|
35
|
+
*
|
|
36
|
+
* Each step is a separate method so that CI wrappers can run and report them
|
|
37
|
+
* one by one, in order: prepare(), checkVersions(), checkout(), sync() and
|
|
38
|
+
* commit(), followed by cleanup() in a finally block. The steps share the
|
|
39
|
+
* temporary working copy on the instance. run() does all of it, or nothing
|
|
40
|
+
* when there's nothing to write.
|
|
41
|
+
*/
|
|
42
|
+
export declare class SvnDeploy {
|
|
43
|
+
readonly options: DeployOptions;
|
|
44
|
+
readonly logger: Logger;
|
|
45
|
+
readonly svn: Svn;
|
|
46
|
+
/** Temporary directory with the working copy and extracted ZIP. */
|
|
47
|
+
tempDir: string | null;
|
|
48
|
+
/** Directory with the plugin files to deploy. */
|
|
49
|
+
filesDir: string | null;
|
|
50
|
+
/** Deployed version, from the options or the Version header. */
|
|
51
|
+
version: string | null;
|
|
52
|
+
/** Working copy changes after sync(). */
|
|
53
|
+
changes: SvnStatusEntry[];
|
|
54
|
+
/**
|
|
55
|
+
* @param options Options from resolveDeployOptions().
|
|
56
|
+
* @param logger Logger instance.
|
|
57
|
+
* @param svn SVN client, with the credentials from the options by default.
|
|
58
|
+
*/
|
|
59
|
+
constructor(options: DeployOptions, logger?: Logger, svn?: Svn);
|
|
60
|
+
/**
|
|
61
|
+
* Get the working copy path.
|
|
62
|
+
*
|
|
63
|
+
* @return Absolute path.
|
|
64
|
+
* @throws {Error} Before prepare().
|
|
65
|
+
*/
|
|
66
|
+
getWorkingCopy(): string;
|
|
67
|
+
/**
|
|
68
|
+
* Create the temporary directory and, with fromZip, extract the archive
|
|
69
|
+
* into it after checking that every entry is safe and inside the {slug}/
|
|
70
|
+
* root folder.
|
|
71
|
+
*/
|
|
72
|
+
prepare(): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Check the plugin files before anything is written. A trunk sync needs
|
|
75
|
+
* a plugin with a Version header that matches the version, and with an
|
|
76
|
+
* SVN tag also a readme Stable tag that matches. Readme-only updates need
|
|
77
|
+
* the readme.
|
|
78
|
+
*
|
|
79
|
+
* @throws {ValidationError} When the files or versions don't match.
|
|
80
|
+
*/
|
|
81
|
+
checkVersions(): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Read the plugin headers of the files to deploy.
|
|
84
|
+
*
|
|
85
|
+
* @param sourceDir Directory with the plugin files.
|
|
86
|
+
* @return Plugin metadata.
|
|
87
|
+
* @throws {ValidationError} If the directory isn't a plugin.
|
|
88
|
+
*/
|
|
89
|
+
readPluginMeta(sourceDir: string): Promise<PackageMeta>;
|
|
90
|
+
/**
|
|
91
|
+
* Create a sparse checkout with only the directories the flags write to,
|
|
92
|
+
* creating the ones that don't exist in the repository yet.
|
|
93
|
+
*
|
|
94
|
+
* @throws {ValidationError} If the SVN tag exists and force isn't set.
|
|
95
|
+
*/
|
|
96
|
+
checkout(): Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Create and add a top-level directory that's missing from the repository,
|
|
99
|
+
* like assets/ in a new plugin repository.
|
|
100
|
+
*
|
|
101
|
+
* @param path Path relative to the working copy.
|
|
102
|
+
*/
|
|
103
|
+
addMissingDirectory(path: string): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Check if the SVN tag exists in the working copy.
|
|
106
|
+
*
|
|
107
|
+
* @return Whether tags/<version> exists.
|
|
108
|
+
*/
|
|
109
|
+
hasTag(): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Copy the files for each write flag into the working copy and stage
|
|
112
|
+
* additions, deletions and asset MIME types, then copy trunk to the tag.
|
|
113
|
+
*/
|
|
114
|
+
sync(): Promise<void>;
|
|
115
|
+
/**
|
|
116
|
+
* List the plugin files to deploy. Files from a ZIP are already selected,
|
|
117
|
+
* so the ignore file and exclude patterns only apply to a directory;
|
|
118
|
+
* version control metadata is left out of both.
|
|
119
|
+
*
|
|
120
|
+
* @param sourceDir Directory with the plugin files.
|
|
121
|
+
* @return Relative file paths.
|
|
122
|
+
*/
|
|
123
|
+
getSourceFiles(sourceDir: string): Promise<string[]>;
|
|
124
|
+
/**
|
|
125
|
+
* Sync every file of the assets directory to assets/.
|
|
126
|
+
*/
|
|
127
|
+
syncAssets(): Promise<void>;
|
|
128
|
+
/**
|
|
129
|
+
* Add unversioned files and remove missing ones in svn.
|
|
130
|
+
*/
|
|
131
|
+
stage(): Promise<void>;
|
|
132
|
+
/**
|
|
133
|
+
* Set svn:mime-type on the asset images, grouped by type.
|
|
134
|
+
*/
|
|
135
|
+
setAssetMimeTypes(): Promise<void>;
|
|
136
|
+
/**
|
|
137
|
+
* Copy trunk to the SVN tag, replacing an existing tag with force.
|
|
138
|
+
*/
|
|
139
|
+
copyTag(): Promise<void>;
|
|
140
|
+
/**
|
|
141
|
+
* Commit the working copy unless there are no changes or this is a dry
|
|
142
|
+
* run. Credentials are only needed here, and not for file:// URLs.
|
|
143
|
+
*
|
|
144
|
+
* @return Deploy result.
|
|
145
|
+
* @throws {CredentialsError} Without a username and password.
|
|
146
|
+
*/
|
|
147
|
+
commit(): Promise<DeployResult>;
|
|
148
|
+
/**
|
|
149
|
+
* Check that the SVN client has the credentials for a commit.
|
|
150
|
+
*
|
|
151
|
+
* @throws {CredentialsError} Without a username and password.
|
|
152
|
+
*/
|
|
153
|
+
checkCredentials(): void;
|
|
154
|
+
/**
|
|
155
|
+
* Remove the temporary working copy and extracted files.
|
|
156
|
+
*/
|
|
157
|
+
cleanup(): Promise<void>;
|
|
158
|
+
/**
|
|
159
|
+
* Run all steps in order, or nothing when there is nothing to write.
|
|
160
|
+
*
|
|
161
|
+
* @return Deploy result.
|
|
162
|
+
*/
|
|
163
|
+
run(): Promise<DeployResult>;
|
|
164
|
+
/**
|
|
165
|
+
* Describe the SVN changes that the write flags ask for.
|
|
166
|
+
*
|
|
167
|
+
* @return Planned changes, in order.
|
|
168
|
+
*/
|
|
169
|
+
getPlan(): string[];
|
|
170
|
+
/**
|
|
171
|
+
* Get the working copy paths to update after the sparse checkout. Tags
|
|
172
|
+
* only need their directory listing to check for an existing version.
|
|
173
|
+
*
|
|
174
|
+
* @return Relative paths with their SVN checkout depth.
|
|
175
|
+
*/
|
|
176
|
+
getCheckoutPaths(): CheckoutPath[];
|
|
177
|
+
/**
|
|
178
|
+
* Check if anything will be written to SVN.
|
|
179
|
+
*
|
|
180
|
+
* @return Whether to check out and commit.
|
|
181
|
+
*/
|
|
182
|
+
hasWrites(): boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Check if the deploy reads plugin files, which assets-only runs don't.
|
|
185
|
+
*
|
|
186
|
+
* @return Whether trunk or the readme is written.
|
|
187
|
+
*/
|
|
188
|
+
needsFiles(): boolean;
|
|
189
|
+
/**
|
|
190
|
+
* Describe the files that are synced, including the ignore rules.
|
|
191
|
+
*
|
|
192
|
+
* @return Source description for the log.
|
|
193
|
+
*/
|
|
194
|
+
describeSource(): string;
|
|
195
|
+
/**
|
|
196
|
+
* Describe the ignore file and exclude patterns applied to the source.
|
|
197
|
+
*
|
|
198
|
+
* @return Rules description, empty without rules.
|
|
199
|
+
*/
|
|
200
|
+
describeRules(): string;
|
|
201
|
+
/**
|
|
202
|
+
* Get the path of the tag in the working copy.
|
|
203
|
+
*
|
|
204
|
+
* @return Relative path, or null without a tag.
|
|
205
|
+
*/
|
|
206
|
+
getTagPath(): string | null;
|
|
207
|
+
getDefaultMessage(): string;
|
|
208
|
+
}
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { extname, join, posix } from 'node:path';
|
|
5
|
+
import ignore from 'ignore';
|
|
6
|
+
import { Logger } from "./logger.js";
|
|
7
|
+
import { getDeployOptionWarnings } from "./deploy-options.js";
|
|
8
|
+
import { CredentialsError, ValidationError } from "./errors.js";
|
|
9
|
+
import { loadFileRules, selectFiles } from "./file-selection.js";
|
|
10
|
+
import { readPackageMeta } from "./package-meta.js";
|
|
11
|
+
import { Svn } from "./svn.js";
|
|
12
|
+
import { copyFileIfChanged, syncDirectory } from "./sync.js";
|
|
13
|
+
import { extractZip } from "./unzip.js";
|
|
14
|
+
import { findVersionMismatches } from "./version-check.js";
|
|
15
|
+
/**
|
|
16
|
+
* MIME types of the plugin directory asset formats. svn guesses binary
|
|
17
|
+
* types, but WordPress.org serves assets with the svn:mime-type property,
|
|
18
|
+
* so it's set explicitly. WordPress.org documents PNG, JPEG and GIF images
|
|
19
|
+
* and SVG icons; other formats keep the type svn assigns.
|
|
20
|
+
*/
|
|
21
|
+
export const ASSET_MIME_TYPES = {
|
|
22
|
+
'.png': 'image/png',
|
|
23
|
+
'.jpg': 'image/jpeg',
|
|
24
|
+
'.jpeg': 'image/jpeg',
|
|
25
|
+
'.gif': 'image/gif',
|
|
26
|
+
'.svg': 'image/svg+xml',
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Deploy a plugin to the WordPress.org plugin SVN repository.
|
|
30
|
+
*
|
|
31
|
+
* Each step is a separate method so that CI wrappers can run and report them
|
|
32
|
+
* one by one, in order: prepare(), checkVersions(), checkout(), sync() and
|
|
33
|
+
* commit(), followed by cleanup() in a finally block. The steps share the
|
|
34
|
+
* temporary working copy on the instance. run() does all of it, or nothing
|
|
35
|
+
* when there's nothing to write.
|
|
36
|
+
*/
|
|
37
|
+
export class SvnDeploy {
|
|
38
|
+
options;
|
|
39
|
+
logger;
|
|
40
|
+
svn;
|
|
41
|
+
/** Temporary directory with the working copy and extracted ZIP. */
|
|
42
|
+
tempDir = null;
|
|
43
|
+
/** Directory with the plugin files to deploy. */
|
|
44
|
+
filesDir = null;
|
|
45
|
+
/** Deployed version, from the options or the Version header. */
|
|
46
|
+
version = null;
|
|
47
|
+
/** Working copy changes after sync(). */
|
|
48
|
+
changes = [];
|
|
49
|
+
/**
|
|
50
|
+
* @param options Options from resolveDeployOptions().
|
|
51
|
+
* @param logger Logger instance.
|
|
52
|
+
* @param svn SVN client, with the credentials from the options by default.
|
|
53
|
+
*/
|
|
54
|
+
constructor(options, logger = new Logger(), svn) {
|
|
55
|
+
this.options = options;
|
|
56
|
+
this.logger = logger;
|
|
57
|
+
this.version = options.version;
|
|
58
|
+
this.svn =
|
|
59
|
+
svn ??
|
|
60
|
+
new Svn({
|
|
61
|
+
username: options.svnUsername,
|
|
62
|
+
password: options.svnPassword,
|
|
63
|
+
logger,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get the working copy path.
|
|
68
|
+
*
|
|
69
|
+
* @return Absolute path.
|
|
70
|
+
* @throws {Error} Before prepare().
|
|
71
|
+
*/
|
|
72
|
+
getWorkingCopy() {
|
|
73
|
+
if (!this.tempDir) {
|
|
74
|
+
throw new Error('Call prepare() before the other deploy steps.');
|
|
75
|
+
}
|
|
76
|
+
return join(this.tempDir, 'svn');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Create the temporary directory and, with fromZip, extract the archive
|
|
80
|
+
* into it after checking that every entry is safe and inside the {slug}/
|
|
81
|
+
* root folder.
|
|
82
|
+
*/
|
|
83
|
+
async prepare() {
|
|
84
|
+
const { fromZip, sourceDir, slug } = this.options;
|
|
85
|
+
this.tempDir = await mkdtemp(join(tmpdir(), `wp-release-${slug}-`));
|
|
86
|
+
this.filesDir = sourceDir;
|
|
87
|
+
if (fromZip && this.needsFiles()) {
|
|
88
|
+
this.filesDir = join(this.tempDir, 'files');
|
|
89
|
+
await mkdir(this.filesDir);
|
|
90
|
+
await extractZip({
|
|
91
|
+
zipPath: fromZip,
|
|
92
|
+
targetDir: this.filesDir,
|
|
93
|
+
rootFolder: slug,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Check the plugin files before anything is written. A trunk sync needs
|
|
99
|
+
* a plugin with a Version header that matches the version, and with an
|
|
100
|
+
* SVN tag also a readme Stable tag that matches. Readme-only updates need
|
|
101
|
+
* the readme.
|
|
102
|
+
*
|
|
103
|
+
* @throws {ValidationError} When the files or versions don't match.
|
|
104
|
+
*/
|
|
105
|
+
async checkVersions() {
|
|
106
|
+
const { trunk, readmeOnly, svnTag, slug, readme } = this.options;
|
|
107
|
+
const sourceDir = this.filesDir ?? this.options.sourceDir;
|
|
108
|
+
if (trunk || readmeOnly) {
|
|
109
|
+
const meta = await this.readPluginMeta(sourceDir);
|
|
110
|
+
if (readmeOnly && !meta.readme) {
|
|
111
|
+
throw new ValidationError(`${readme} doesn't exist in ${sourceDir}.`);
|
|
112
|
+
}
|
|
113
|
+
if (trunk) {
|
|
114
|
+
const { version, mismatches } = await findVersionMismatches({
|
|
115
|
+
sourceDir,
|
|
116
|
+
meta,
|
|
117
|
+
slug,
|
|
118
|
+
version: this.options.version,
|
|
119
|
+
stableTag: Boolean(svnTag),
|
|
120
|
+
});
|
|
121
|
+
this.version = version;
|
|
122
|
+
if (mismatches.length) {
|
|
123
|
+
throw new ValidationError(mismatches
|
|
124
|
+
.map(({ message }) => message)
|
|
125
|
+
.join('\n'));
|
|
126
|
+
}
|
|
127
|
+
this.logger.info(`Deploying version ${version}.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Read the plugin headers of the files to deploy.
|
|
133
|
+
*
|
|
134
|
+
* @param sourceDir Directory with the plugin files.
|
|
135
|
+
* @return Plugin metadata.
|
|
136
|
+
* @throws {ValidationError} If the directory isn't a plugin.
|
|
137
|
+
*/
|
|
138
|
+
async readPluginMeta(sourceDir) {
|
|
139
|
+
const { slug, readme } = this.options;
|
|
140
|
+
const meta = await readPackageMeta({ dir: sourceDir, slug, readme });
|
|
141
|
+
if ('theme' === meta?.type) {
|
|
142
|
+
throw new ValidationError(`${sourceDir} is a theme. Themes are released by uploading a ZIP on WordPress.org, use "wp-release zip".`);
|
|
143
|
+
}
|
|
144
|
+
if (!meta) {
|
|
145
|
+
throw new ValidationError(`No main plugin file with a Plugin Name header in ${sourceDir}.`);
|
|
146
|
+
}
|
|
147
|
+
return meta;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Create a sparse checkout with only the directories the flags write to,
|
|
151
|
+
* creating the ones that don't exist in the repository yet.
|
|
152
|
+
*
|
|
153
|
+
* @throws {ValidationError} If the SVN tag exists and force isn't set.
|
|
154
|
+
*/
|
|
155
|
+
async checkout() {
|
|
156
|
+
const workingCopy = this.getWorkingCopy();
|
|
157
|
+
const tagPath = this.getTagPath();
|
|
158
|
+
await this.svn.checkout({
|
|
159
|
+
url: this.options.svnUrl,
|
|
160
|
+
path: workingCopy,
|
|
161
|
+
depth: 'immediates',
|
|
162
|
+
});
|
|
163
|
+
for (const { path, depth } of this.getCheckoutPaths()) {
|
|
164
|
+
await this.svn.update({ path: join(workingCopy, path), depth });
|
|
165
|
+
await this.addMissingDirectory(path);
|
|
166
|
+
}
|
|
167
|
+
if (tagPath && !this.options.force && this.hasTag()) {
|
|
168
|
+
throw new ValidationError(`${tagPath} already exists in ${this.options.svnUrl}. Pass --force to replace it.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Create and add a top-level directory that's missing from the repository,
|
|
173
|
+
* like assets/ in a new plugin repository.
|
|
174
|
+
*
|
|
175
|
+
* @param path Path relative to the working copy.
|
|
176
|
+
*/
|
|
177
|
+
async addMissingDirectory(path) {
|
|
178
|
+
const workingCopy = this.getWorkingCopy();
|
|
179
|
+
if (!existsSync(join(workingCopy, path))) {
|
|
180
|
+
await mkdir(join(workingCopy, path));
|
|
181
|
+
await this.svn.add({ paths: [path], cwd: workingCopy });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Check if the SVN tag exists in the working copy.
|
|
186
|
+
*
|
|
187
|
+
* @return Whether tags/<version> exists.
|
|
188
|
+
*/
|
|
189
|
+
hasTag() {
|
|
190
|
+
const tagPath = this.getTagPath();
|
|
191
|
+
return Boolean(tagPath && existsSync(join(this.getWorkingCopy(), tagPath)));
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Copy the files for each write flag into the working copy and stage
|
|
195
|
+
* additions, deletions and asset MIME types, then copy trunk to the tag.
|
|
196
|
+
*/
|
|
197
|
+
async sync() {
|
|
198
|
+
const { trunk, readmeOnly, assets, readme } = this.options;
|
|
199
|
+
const workingCopy = this.getWorkingCopy();
|
|
200
|
+
const sourceDir = this.filesDir ?? this.options.sourceDir;
|
|
201
|
+
if (trunk) {
|
|
202
|
+
await syncDirectory({
|
|
203
|
+
sourceDir,
|
|
204
|
+
files: await this.getSourceFiles(sourceDir),
|
|
205
|
+
targetDir: join(workingCopy, 'trunk'),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (readmeOnly) {
|
|
209
|
+
await copyFileIfChanged(join(sourceDir, readme), join(workingCopy, 'trunk', readme));
|
|
210
|
+
}
|
|
211
|
+
if (assets) {
|
|
212
|
+
await this.syncAssets();
|
|
213
|
+
}
|
|
214
|
+
await this.stage();
|
|
215
|
+
if (assets) {
|
|
216
|
+
await this.setAssetMimeTypes();
|
|
217
|
+
}
|
|
218
|
+
await this.copyTag();
|
|
219
|
+
this.changes = await this.svn.status(workingCopy);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* List the plugin files to deploy. Files from a ZIP are already selected,
|
|
223
|
+
* so the ignore file and exclude patterns only apply to a directory;
|
|
224
|
+
* version control metadata is left out of both.
|
|
225
|
+
*
|
|
226
|
+
* @param sourceDir Directory with the plugin files.
|
|
227
|
+
* @return Relative file paths.
|
|
228
|
+
*/
|
|
229
|
+
async getSourceFiles(sourceDir) {
|
|
230
|
+
const rules = await loadFileRules(this.options.fromZip ? {} : this.options);
|
|
231
|
+
return selectFiles(sourceDir, rules);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Sync every file of the assets directory to assets/.
|
|
235
|
+
*/
|
|
236
|
+
async syncAssets() {
|
|
237
|
+
const assetsDir = this.options.assetsDir ?? '';
|
|
238
|
+
await syncDirectory({
|
|
239
|
+
sourceDir: assetsDir,
|
|
240
|
+
files: await selectFiles(assetsDir, await loadFileRules({})),
|
|
241
|
+
targetDir: join(this.getWorkingCopy(), 'assets'),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Add unversioned files and remove missing ones in svn.
|
|
246
|
+
*/
|
|
247
|
+
async stage() {
|
|
248
|
+
const workingCopy = this.getWorkingCopy();
|
|
249
|
+
const status = await this.svn.status(workingCopy);
|
|
250
|
+
const pathsWith = (type) => status
|
|
251
|
+
.filter((entry) => type === entry.status)
|
|
252
|
+
.map(({ path }) => path);
|
|
253
|
+
await this.svn.add({
|
|
254
|
+
paths: pathsWith('unversioned'),
|
|
255
|
+
cwd: workingCopy,
|
|
256
|
+
});
|
|
257
|
+
await this.svn.remove({
|
|
258
|
+
paths: pathsWith('missing'),
|
|
259
|
+
cwd: workingCopy,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Set svn:mime-type on the asset images, grouped by type.
|
|
264
|
+
*/
|
|
265
|
+
async setAssetMimeTypes() {
|
|
266
|
+
const workingCopy = this.getWorkingCopy();
|
|
267
|
+
const files = await selectFiles(join(workingCopy, 'assets'), ignore().add('.svn/'));
|
|
268
|
+
for (const [extension, mimeType] of Object.entries(ASSET_MIME_TYPES)) {
|
|
269
|
+
await this.svn.propset({
|
|
270
|
+
name: 'svn:mime-type',
|
|
271
|
+
value: mimeType,
|
|
272
|
+
paths: files
|
|
273
|
+
.filter((file) => extension === extname(file).toLowerCase())
|
|
274
|
+
.map((file) => posix.join('assets', file)),
|
|
275
|
+
cwd: workingCopy,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Copy trunk to the SVN tag, replacing an existing tag with force.
|
|
281
|
+
*/
|
|
282
|
+
async copyTag() {
|
|
283
|
+
const tagPath = this.getTagPath();
|
|
284
|
+
const workingCopy = this.getWorkingCopy();
|
|
285
|
+
if (tagPath && this.hasTag()) {
|
|
286
|
+
this.logger.warning(`Replacing the existing ${tagPath}.`);
|
|
287
|
+
await this.svn.remove({ paths: [tagPath], cwd: workingCopy });
|
|
288
|
+
}
|
|
289
|
+
if (tagPath) {
|
|
290
|
+
await this.svn.copy({
|
|
291
|
+
from: 'trunk',
|
|
292
|
+
to: tagPath,
|
|
293
|
+
cwd: workingCopy,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Commit the working copy unless there are no changes or this is a dry
|
|
299
|
+
* run. Credentials are only needed here, and not for file:// URLs.
|
|
300
|
+
*
|
|
301
|
+
* @return Deploy result.
|
|
302
|
+
* @throws {CredentialsError} Without a username and password.
|
|
303
|
+
*/
|
|
304
|
+
async commit() {
|
|
305
|
+
const { changes, version } = this;
|
|
306
|
+
const result = { committed: false, revision: null, version, changes };
|
|
307
|
+
if (!changes.length) {
|
|
308
|
+
this.logger.notice('No changes to commit.');
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
await this.logger.group('SVN status', () => changes.forEach(({ status, path }) => this.logger.info(`${status.padEnd(12)}${path}`)));
|
|
312
|
+
if (this.options.dryRun) {
|
|
313
|
+
this.logger.notice(`Dry run, skipped committing ${changes.length} changes.`);
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
this.checkCredentials();
|
|
317
|
+
const revision = await this.svn.commit({
|
|
318
|
+
path: this.getWorkingCopy(),
|
|
319
|
+
message: this.options.message ?? this.getDefaultMessage(),
|
|
320
|
+
});
|
|
321
|
+
this.logger.notice(`Committed revision ${revision}.`);
|
|
322
|
+
return { ...result, committed: true, revision };
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Check that the SVN client has the credentials for a commit.
|
|
326
|
+
*
|
|
327
|
+
* @throws {CredentialsError} Without a username and password.
|
|
328
|
+
*/
|
|
329
|
+
checkCredentials() {
|
|
330
|
+
const { svnUrl } = this.options;
|
|
331
|
+
const { username, password } = this.svn;
|
|
332
|
+
if (!svnUrl.startsWith('file://') && !(username && password)) {
|
|
333
|
+
throw new CredentialsError(`Committing to ${svnUrl} needs SVN credentials. Set SVN_USERNAME and SVN_PASSWORD, or use --dry-run.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Remove the temporary working copy and extracted files.
|
|
338
|
+
*/
|
|
339
|
+
async cleanup() {
|
|
340
|
+
if (this.tempDir) {
|
|
341
|
+
await rm(this.tempDir, { recursive: true, force: true });
|
|
342
|
+
this.tempDir = null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Run all steps in order, or nothing when there is nothing to write.
|
|
347
|
+
*
|
|
348
|
+
* @return Deploy result.
|
|
349
|
+
*/
|
|
350
|
+
async run() {
|
|
351
|
+
if (this.hasWrites()) {
|
|
352
|
+
getDeployOptionWarnings(this.options).forEach((warning) => this.logger.warning(warning));
|
|
353
|
+
await this.logger.group('Planned changes', () => this.getPlan().forEach((step) => this.logger.info(step)));
|
|
354
|
+
try {
|
|
355
|
+
await this.prepare();
|
|
356
|
+
await this.checkVersions();
|
|
357
|
+
await this.checkout();
|
|
358
|
+
await this.sync();
|
|
359
|
+
return await this.commit();
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
await this.cleanup();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
this.logger.notice('Nothing to deploy.');
|
|
366
|
+
return {
|
|
367
|
+
committed: false,
|
|
368
|
+
revision: null,
|
|
369
|
+
version: this.version,
|
|
370
|
+
changes: [],
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Describe the SVN changes that the write flags ask for.
|
|
375
|
+
*
|
|
376
|
+
* @return Planned changes, in order.
|
|
377
|
+
*/
|
|
378
|
+
getPlan() {
|
|
379
|
+
const { svnUrl, trunk, readmeOnly, assets, assetsDir, readme, force } = this.options;
|
|
380
|
+
const tagPath = this.getTagPath();
|
|
381
|
+
const message = this.options.message ?? this.getDefaultMessage();
|
|
382
|
+
const plan = [
|
|
383
|
+
`Check out ${svnUrl} with ${this.getCheckoutPaths()
|
|
384
|
+
.map(({ path }) => path)
|
|
385
|
+
.join(', ')}`,
|
|
386
|
+
tagPath && !force && `Stop if ${tagPath} already exists`,
|
|
387
|
+
tagPath && force && `Replace ${tagPath} if it exists`,
|
|
388
|
+
trunk && `Sync ${this.describeSource()} to trunk/`,
|
|
389
|
+
readmeOnly && `Copy ${readme} to trunk/`,
|
|
390
|
+
tagPath && `Copy trunk/ to ${tagPath}`,
|
|
391
|
+
assets && `Sync ${assetsDir} to assets/`,
|
|
392
|
+
this.options.dryRun
|
|
393
|
+
? `Skip the commit "${message}" (dry run)`
|
|
394
|
+
: `Commit "${message}"`,
|
|
395
|
+
];
|
|
396
|
+
return plan.filter((step) => Boolean(step));
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Get the working copy paths to update after the sparse checkout. Tags
|
|
400
|
+
* only need their directory listing to check for an existing version.
|
|
401
|
+
*
|
|
402
|
+
* @return Relative paths with their SVN checkout depth.
|
|
403
|
+
*/
|
|
404
|
+
getCheckoutPaths() {
|
|
405
|
+
const { trunk, readmeOnly, svnTag, assets } = this.options;
|
|
406
|
+
const paths = [];
|
|
407
|
+
if (trunk || readmeOnly) {
|
|
408
|
+
paths.push({ path: 'trunk', depth: 'infinity' });
|
|
409
|
+
}
|
|
410
|
+
if (svnTag) {
|
|
411
|
+
paths.push({ path: 'tags', depth: 'immediates' });
|
|
412
|
+
}
|
|
413
|
+
if (assets) {
|
|
414
|
+
paths.push({ path: 'assets', depth: 'infinity' });
|
|
415
|
+
}
|
|
416
|
+
return paths;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Check if anything will be written to SVN.
|
|
420
|
+
*
|
|
421
|
+
* @return Whether to check out and commit.
|
|
422
|
+
*/
|
|
423
|
+
hasWrites() {
|
|
424
|
+
const { trunk, readmeOnly, svnTag, assets } = this.options;
|
|
425
|
+
return Boolean(trunk || readmeOnly || svnTag || assets);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Check if the deploy reads plugin files, which assets-only runs don't.
|
|
429
|
+
*
|
|
430
|
+
* @return Whether trunk or the readme is written.
|
|
431
|
+
*/
|
|
432
|
+
needsFiles() {
|
|
433
|
+
return this.options.trunk || this.options.readmeOnly;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Describe the files that are synced, including the ignore rules.
|
|
437
|
+
*
|
|
438
|
+
* @return Source description for the log.
|
|
439
|
+
*/
|
|
440
|
+
describeSource() {
|
|
441
|
+
const { fromZip, sourceDir } = this.options;
|
|
442
|
+
if (fromZip) {
|
|
443
|
+
return fromZip;
|
|
444
|
+
}
|
|
445
|
+
const rules = this.describeRules();
|
|
446
|
+
return rules ? `${sourceDir} (${rules})` : sourceDir;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Describe the ignore file and exclude patterns applied to the source.
|
|
450
|
+
*
|
|
451
|
+
* @return Rules description, empty without rules.
|
|
452
|
+
*/
|
|
453
|
+
describeRules() {
|
|
454
|
+
const { distignore, exclude } = this.options;
|
|
455
|
+
const rules = [
|
|
456
|
+
distignore && `rules from ${distignore}`,
|
|
457
|
+
exclude.length > 0 && `excluding ${exclude.join(', ')}`,
|
|
458
|
+
];
|
|
459
|
+
return rules.filter(Boolean).join(', ');
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Get the path of the tag in the working copy.
|
|
463
|
+
*
|
|
464
|
+
* @return Relative path, or null without a tag.
|
|
465
|
+
*/
|
|
466
|
+
getTagPath() {
|
|
467
|
+
const { svnTag } = this.options;
|
|
468
|
+
if (svnTag) {
|
|
469
|
+
return `tags/${svnTag}`;
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
getDefaultMessage() {
|
|
474
|
+
const { trunk, readmeOnly, svnTag, assets } = this.options;
|
|
475
|
+
if (svnTag) {
|
|
476
|
+
return `Release version ${svnTag}`;
|
|
477
|
+
}
|
|
478
|
+
const parts = [
|
|
479
|
+
trunk && 'trunk',
|
|
480
|
+
readmeOnly && 'readme',
|
|
481
|
+
assets && 'assets',
|
|
482
|
+
];
|
|
483
|
+
return `Update ${parts.filter(Boolean).join(' and ')}`;
|
|
484
|
+
}
|
|
485
|
+
}
|