rman 2.23.0 → 2.24.0
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/builtins/platforms/node/node-config.interface.d.ts +2 -1
- package/builtins/platforms/node/services/publish.service.js +3 -3
- package/builtins/publish-targets/cloudflare/cloudflare-publish.service.d.ts +7 -0
- package/builtins/publish-targets/cloudflare/cloudflare-publish.service.js +36 -6
- package/builtins/publish-targets/cloudflare/cloudflare.target.d.ts +9 -2
- package/builtins/publish-targets/docker/docker-publish.service.js +56 -5
- package/builtins/publish-targets/docker/docker.target.d.ts +22 -2
- package/commands/publish.command.js +75 -3
- package/constants.js +1 -1
- package/core/interfaces/publish-target.d.ts +14 -1
- package/package.json +1 -1
- package/services/github-release.service.js +4 -3
- package/services/version-plan.service.js +5 -4
- package/utils/git.d.ts +10 -0
- package/utils/git.js +21 -0
- package/utils/publish-files.d.ts +50 -0
- package/utils/publish-files.js +148 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PublishFilesKeys } from '../../../core/interfaces/publish-target.js';
|
|
1
2
|
import type { ConfigValue, RmanConfig, ScopedVars } from '../../../interfaces/rman-config.interface.js';
|
|
2
3
|
import type { CiService } from './services/ci.service.js';
|
|
3
4
|
/**
|
|
@@ -26,7 +27,7 @@ export declare namespace RmanNodeConfig {
|
|
|
26
27
|
*/
|
|
27
28
|
interface NpmPublishOptions extends NpmPublishOptionsKeys, ScopedVars {
|
|
28
29
|
}
|
|
29
|
-
interface NpmPublishOptionsKeys {
|
|
30
|
+
interface NpmPublishOptionsKeys extends PublishFilesKeys {
|
|
30
31
|
/** Where this package's publishable output lives, relative to its own directory (e.g.
|
|
31
32
|
* `"build"`). Per-package cascaded, so a root `"[*]"` block can say it once for the whole
|
|
32
33
|
* repository instead of repeating `publishConfig.directory` in every `package.json` - which
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { exec } from '../../../../utils/exec.js';
|
|
20
|
-
import { GitHelper } from '../../../../utils/git.js';
|
|
20
|
+
import { dirtyReason, filesUnder, GitHelper } from '../../../../utils/git.js';
|
|
21
21
|
import { filterPackages } from '../../../../utils/package-filter.js';
|
|
22
22
|
import { isCalendarVersion } from '../../../../utils/release-version.js';
|
|
23
23
|
import { npmViewPackage } from '../../../publish-targets/npm/npm-view.js';
|
|
@@ -197,7 +197,7 @@ export var PublishService;
|
|
|
197
197
|
const git = new GitHelper({ cwd: repository.dirname });
|
|
198
198
|
const packages = filterPackages(repository.getPackages({ toposort: true }), options).filter(pkg => options.shipsHere?.(pkg) ?? true);
|
|
199
199
|
const dirtyFiles = await git.listDirtyFiles({ absolute: true });
|
|
200
|
-
const isDirty = (pkg) => dirtyFiles
|
|
200
|
+
const isDirty = (pkg) => filesUnder(dirtyFiles, pkg.dirname).length > 0;
|
|
201
201
|
/** Per package, not per run: `resolveRegistry` reads the package's own `publishConfig.registry`
|
|
202
202
|
* when `--registry` was not given, so the question is asked where the answer lives. The test
|
|
203
203
|
* seam keeps its `(name, cwd)` shape - a stub answers from a map and has no registry to
|
|
@@ -250,7 +250,7 @@ export var PublishService;
|
|
|
250
250
|
package: pkg,
|
|
251
251
|
version: pkg.version,
|
|
252
252
|
status: options.ignoreDirty ? 'skip' : 'error',
|
|
253
|
-
reason:
|
|
253
|
+
reason: dirtyReason(filesUnder(dirtyFiles, pkg.dirname), repository.dirname),
|
|
254
254
|
});
|
|
255
255
|
}
|
|
256
256
|
else {
|
|
@@ -46,6 +46,9 @@ export declare class CloudflarePublishService extends Service {
|
|
|
46
46
|
protected workerHas(deploy: CloudflarePublishService.WorkersDeploy): Promise<boolean>;
|
|
47
47
|
/** Runs the deploy, its output shown on stderr as it comes and kept for the reason if it fails. */
|
|
48
48
|
protected deploy(deploy: CloudflarePublishService.Deploy): Promise<void>;
|
|
49
|
+
/** `--env <name>` when the Worker deploys to a named wrangler environment - for the deploy and for
|
|
50
|
+
* the versions list, which would otherwise ask about a different Worker. */
|
|
51
|
+
protected envArgs(deploy: CloudflarePublishService.WorkersDeploy): string[];
|
|
49
52
|
/**
|
|
50
53
|
* `wrangler`, from the package's own or the repository's `node_modules` when it is installed there,
|
|
51
54
|
* and through `npx` when it is not - so a package that never added it as a dependency still
|
|
@@ -84,6 +87,10 @@ export declare namespace CloudflarePublishService {
|
|
|
84
87
|
tag: string;
|
|
85
88
|
/** Absolute path of the wrangler configuration. */
|
|
86
89
|
config: string;
|
|
90
|
+
/** The wrangler environment, when one is named. */
|
|
91
|
+
env?: string;
|
|
92
|
+
/** `--var` entries. */
|
|
93
|
+
variables: Record<string, string>;
|
|
87
94
|
}
|
|
88
95
|
export type Deploy = PagesDeploy | WorkersDeploy;
|
|
89
96
|
/** One package's outcome - a `PublishTarget.Entry` carrying where it deploys. */
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Service } from '../../../core/classes/service.js';
|
|
4
4
|
import { targetsOf } from '../../../core/interfaces/publish-target.js';
|
|
5
|
-
import { GitHelper } from '../../../utils/git.js';
|
|
5
|
+
import { dirtyReason, filesUnder, GitHelper } from '../../../utils/git.js';
|
|
6
6
|
import { filterPackages } from '../../../utils/package-filter.js';
|
|
7
7
|
import { runBin } from '../../../utils/run-bin.js';
|
|
8
8
|
/** The name this target answers to in `publish.target` and `--target`. */
|
|
@@ -34,7 +34,7 @@ export class CloudflarePublishService extends Service {
|
|
|
34
34
|
const git = new GitHelper({ cwd: repository.dirname });
|
|
35
35
|
const packages = filterPackages(repository.getPackages({ toposort: true }), options).filter(pkg => targetsOf(this.app, pkg).some(t => t.name === CLOUDFLARE_TARGET) && !pkg.config.publish?.skip);
|
|
36
36
|
const dirtyFiles = await git.listDirtyFiles({ absolute: true });
|
|
37
|
-
const isDirty = (pkg) => dirtyFiles
|
|
37
|
+
const isDirty = (pkg) => filesUnder(dirtyFiles, pkg.dirname).length > 0;
|
|
38
38
|
const credentials = this.credentials();
|
|
39
39
|
const isDeployed = deps.isDeployed ?? ((deploy) => this.isDeployed(deploy));
|
|
40
40
|
return Promise.all(packages.map(async (pkg) => {
|
|
@@ -48,7 +48,11 @@ export class CloudflarePublishService extends Service {
|
|
|
48
48
|
}
|
|
49
49
|
const located = { ...base, deploy, detail: deploy.label };
|
|
50
50
|
if (isDirty(pkg)) {
|
|
51
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
...located,
|
|
53
|
+
status: options.ignoreDirty ? 'skip' : 'error',
|
|
54
|
+
reason: dirtyReason(filesUnder(dirtyFiles, pkg.dirname), repository.dirname),
|
|
55
|
+
};
|
|
52
56
|
}
|
|
53
57
|
if (!credentials) {
|
|
54
58
|
return {
|
|
@@ -91,6 +95,13 @@ export class CloudflarePublishService extends Service {
|
|
|
91
95
|
throw new Error('"cloudflare" is a publish target but "publish.cloudflare" is not configured');
|
|
92
96
|
const marker = `${pkg.name}@${pkg.version}`;
|
|
93
97
|
if (cf.kind === 'pages') {
|
|
98
|
+
/** Said rather than ignored: `wrangler pages deploy` has no `--env` or `--var`, and a setting
|
|
99
|
+
* that silently does nothing is the one a reader trusts. */
|
|
100
|
+
for (const key of ['env', 'variables']) {
|
|
101
|
+
if (cf[key] !== undefined) {
|
|
102
|
+
throw new Error(`"publish.cloudflare.${key}" is for kind "workers" - Pages takes settings through "files"`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
94
105
|
const project = cf.project;
|
|
95
106
|
if (!project)
|
|
96
107
|
throw new Error('"publish.cloudflare.project" is required for kind "pages"');
|
|
@@ -108,13 +119,16 @@ export class CloudflarePublishService extends Service {
|
|
|
108
119
|
? `"publish.cloudflare.config" names ${declared}, which does not exist`
|
|
109
120
|
: `kind "workers" needs a wrangler configuration - none of ${this.workerConfigFiles.join(', ')} is in the package`);
|
|
110
121
|
}
|
|
122
|
+
const env = cf.env || undefined;
|
|
111
123
|
return {
|
|
112
124
|
kind: 'workers',
|
|
113
125
|
pkg,
|
|
114
126
|
marker,
|
|
115
127
|
tag: `v${pkg.version}`,
|
|
116
128
|
config,
|
|
117
|
-
|
|
129
|
+
env,
|
|
130
|
+
variables: cf.variables ?? {},
|
|
131
|
+
label: `workers ${path.relative(pkg.dirname, config)}${env ? ` (${env})` : ''}`,
|
|
118
132
|
};
|
|
119
133
|
}
|
|
120
134
|
throw new Error(`"publish.cloudflare.kind" must be "pages" or "workers", not ${JSON.stringify(cf.kind)}`);
|
|
@@ -154,7 +168,8 @@ export class CloudflarePublishService extends Service {
|
|
|
154
168
|
/** A version of the Worker tagged with this version - among the ten most recent wrangler lists. */
|
|
155
169
|
async workerHas(deploy) {
|
|
156
170
|
const lines = [];
|
|
157
|
-
|
|
171
|
+
const argv = ['versions', 'list', '--json', '--config', deploy.config, ...this.envArgs(deploy)];
|
|
172
|
+
await this.wrangler(deploy.pkg, argv, (line, stream) => {
|
|
158
173
|
if (stream === 'stdout')
|
|
159
174
|
lines.push(line);
|
|
160
175
|
});
|
|
@@ -179,7 +194,17 @@ export class CloudflarePublishService extends Service {
|
|
|
179
194
|
deploy.marker,
|
|
180
195
|
'--commit-dirty=true',
|
|
181
196
|
]
|
|
182
|
-
: [
|
|
197
|
+
: [
|
|
198
|
+
'deploy',
|
|
199
|
+
'--config',
|
|
200
|
+
deploy.config,
|
|
201
|
+
...this.envArgs(deploy),
|
|
202
|
+
...Object.entries(deploy.variables).flatMap(([name, value]) => ['--var', `${name}:${value}`]),
|
|
203
|
+
'--tag',
|
|
204
|
+
deploy.tag,
|
|
205
|
+
'--message',
|
|
206
|
+
deploy.marker,
|
|
207
|
+
];
|
|
183
208
|
const lines = [];
|
|
184
209
|
try {
|
|
185
210
|
await this.wrangler(deploy.pkg, argv, line => {
|
|
@@ -194,6 +219,11 @@ export class CloudflarePublishService extends Service {
|
|
|
194
219
|
});
|
|
195
220
|
}
|
|
196
221
|
}
|
|
222
|
+
/** `--env <name>` when the Worker deploys to a named wrangler environment - for the deploy and for
|
|
223
|
+
* the versions list, which would otherwise ask about a different Worker. */
|
|
224
|
+
envArgs(deploy) {
|
|
225
|
+
return deploy.env ? ['--env', deploy.env] : [];
|
|
226
|
+
}
|
|
197
227
|
/**
|
|
198
228
|
* `wrangler`, from the package's own or the repository's `node_modules` when it is installed there,
|
|
199
229
|
* and through `npx` when it is not - so a package that never added it as a dependency still
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PublishTarget } from '../../../core/interfaces/publish-target.js';
|
|
1
|
+
import type { PublishFilesKeys, PublishTarget } from '../../../core/interfaces/publish-target.js';
|
|
2
2
|
import type { ConfigValue, ScopedVars } from '../../../interfaces/rman-config.interface.js';
|
|
3
3
|
/**
|
|
4
4
|
* **`publish.cloudflare.*`** - this target's own config block, declared beside it and reaching
|
|
@@ -9,7 +9,7 @@ import type { ConfigValue, ScopedVars } from '../../../interfaces/rman-config.in
|
|
|
9
9
|
*/
|
|
10
10
|
export interface CloudflarePublishOptions extends CloudflarePublishOptionsKeys, ScopedVars {
|
|
11
11
|
}
|
|
12
|
-
export interface CloudflarePublishOptionsKeys {
|
|
12
|
+
export interface CloudflarePublishOptionsKeys extends PublishFilesKeys {
|
|
13
13
|
/**
|
|
14
14
|
* What the package deploys as: `"pages"` - a directory of static files uploaded to a Pages
|
|
15
15
|
* project (`wrangler pages deploy`) - or `"workers"` - a Worker, static assets included, as its
|
|
@@ -26,6 +26,13 @@ export interface CloudflarePublishOptionsKeys {
|
|
|
26
26
|
/** Workers: the wrangler configuration file, relative to the package. Default: the first of
|
|
27
27
|
* `wrangler.jsonc`, `wrangler.json`, `wrangler.toml` in the package. */
|
|
28
28
|
config?: ConfigValue<string>;
|
|
29
|
+
/** Workers: the wrangler environment to deploy (`--env`) - one the configuration declares,
|
|
30
|
+
* `staging` or `production`. */
|
|
31
|
+
env?: ConfigValue<string>;
|
|
32
|
+
/** Workers: variables the Worker reads from `env` at run time (`--var NAME:value`), over the
|
|
33
|
+
* configuration's own `vars`. Not for secrets - they show in the dashboard; those are
|
|
34
|
+
* `wrangler secret`'s. Pages takes none from the command line: a file through `files` instead. */
|
|
35
|
+
variables?: ConfigValue<Record<string, string>>;
|
|
29
36
|
}
|
|
30
37
|
/**
|
|
31
38
|
* **Cloudflare, as a publish target** - a static site on Pages, or a Worker with its assets.
|
|
@@ -5,8 +5,9 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
import { Service } from '../../../core/classes/service.js';
|
|
6
6
|
import { targetsOf } from '../../../core/interfaces/publish-target.js';
|
|
7
7
|
import { exec } from '../../../utils/exec.js';
|
|
8
|
-
import { GitHelper } from '../../../utils/git.js';
|
|
8
|
+
import { dirtyReason, filesUnder, GitHelper } from '../../../utils/git.js';
|
|
9
9
|
import { filterPackages } from '../../../utils/package-filter.js';
|
|
10
|
+
import { isCalendarVersion } from '../../../utils/release-version.js';
|
|
10
11
|
/**
|
|
11
12
|
* The name this target answers to in `publish.target` and `--target`.
|
|
12
13
|
*
|
|
@@ -42,7 +43,7 @@ export class DockerPublishService extends Service {
|
|
|
42
43
|
* where a package ships. */
|
|
43
44
|
const packages = filterPackages(repository.getPackages({ toposort: true }), options).filter(pkg => targetsOf(this.app, pkg).some(t => t.name === DOCKER_TARGET) && !pkg.config.publish?.skip);
|
|
44
45
|
const dirtyFiles = await git.listDirtyFiles({ absolute: true });
|
|
45
|
-
const isDirty = (pkg) => dirtyFiles
|
|
46
|
+
const isDirty = (pkg) => filesUnder(dirtyFiles, pkg.dirname).length > 0;
|
|
46
47
|
const imageExists = deps.imageExists ?? defaultImageExists;
|
|
47
48
|
const entries = new Map();
|
|
48
49
|
const toCheck = [];
|
|
@@ -72,19 +73,22 @@ export class DockerPublishService extends Service {
|
|
|
72
73
|
image,
|
|
73
74
|
detail: image,
|
|
74
75
|
status: options.ignoreDirty ? 'skip' : 'error',
|
|
75
|
-
reason:
|
|
76
|
+
reason: dirtyReason(filesUnder(dirtyFiles, pkg.dirname), repository.dirname),
|
|
76
77
|
});
|
|
77
78
|
continue;
|
|
78
79
|
}
|
|
79
80
|
toCheck.push({ pkg, image });
|
|
80
81
|
}
|
|
82
|
+
/** What the plan prints beside a package is every tag it pushes, so `--dry-run` shows whether a
|
|
83
|
+
* prerelease would move `latest`. */
|
|
84
|
+
const describe = (pkg, image) => `${image}:${imageTags(pkg).join(', ')}`;
|
|
81
85
|
await Promise.all(toCheck.map(async ({ pkg, image }) => {
|
|
82
86
|
const exists = await imageExists(image, pkg.version);
|
|
83
87
|
entries.set(pkg.name, {
|
|
84
88
|
package: pkg,
|
|
85
89
|
version: pkg.version,
|
|
86
90
|
image,
|
|
87
|
-
detail: image,
|
|
91
|
+
detail: describe(pkg, image),
|
|
88
92
|
status: exists ? 'up-to-date' : 'publish',
|
|
89
93
|
reason: exists ? `registry already has ${image}:${pkg.version}` : 'never published',
|
|
90
94
|
});
|
|
@@ -151,6 +155,40 @@ function resolveImageRef(image, namespaceOverride) {
|
|
|
151
155
|
}
|
|
152
156
|
return `${namespace}/${image}`;
|
|
153
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Every tag an image of `pkg` is pushed under: its version, its floating tag, then
|
|
160
|
+
* `publish.docker.tags`, each once.
|
|
161
|
+
*
|
|
162
|
+
* The floating tag is `latest` for a release and the identifier for a prerelease - `2.0.0-beta.1`
|
|
163
|
+
* goes to `beta`, and one with no word to name it (`2.0.0-1`) to none. A calendar version is a
|
|
164
|
+
* release however its time is spelled.
|
|
165
|
+
*/
|
|
166
|
+
/* **Every image used to be pushed as `latest`**, a beta included, so a plain `docker pull` took
|
|
167
|
+
* whatever was pushed last - the mistake npm's `distTagFor` exists to prevent, made on the other
|
|
168
|
+
* registry. Same rule, same scheme questions. */
|
|
169
|
+
function imageTags(pkg) {
|
|
170
|
+
const version = pkg.version;
|
|
171
|
+
const preview = !isCalendarVersion(version) && pkg.versionScheme.isPrerelease(version);
|
|
172
|
+
const floating = preview ? pkg.versionScheme.prereleaseId(version) : 'latest';
|
|
173
|
+
const extra = (pkg.config.publish?.docker?.tags ?? []).filter((t) => typeof t === 'string' && !!t);
|
|
174
|
+
return [...new Set([version, ...(floating ? [floating] : []), ...extra])];
|
|
175
|
+
}
|
|
176
|
+
/** `--secret`'s value: `"$NAME"` reads the environment, anything else is a file in the package. */
|
|
177
|
+
function secretSpec(pkg, id, value) {
|
|
178
|
+
const env = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(value);
|
|
179
|
+
if (env) {
|
|
180
|
+
if (process.env[env[1]] === undefined) {
|
|
181
|
+
throw new Error(`"publish.docker.secrets.${id}" reads ${env[1]}, which is not set`);
|
|
182
|
+
}
|
|
183
|
+
return `id=${id},env=${env[1]}`;
|
|
184
|
+
}
|
|
185
|
+
return `id=${id},src=${path.resolve(pkg.dirname, value)}`;
|
|
186
|
+
}
|
|
187
|
+
function asList(value) {
|
|
188
|
+
if (!value)
|
|
189
|
+
return [];
|
|
190
|
+
return Array.isArray(value) ? value : [value];
|
|
191
|
+
}
|
|
154
192
|
/** A value of exactly `"$NAME"` expands to `process.env.NAME` (empty string if unset) - anything
|
|
155
193
|
* else (including a value with `$` only as part of a larger string) is passed through verbatim. */
|
|
156
194
|
function expandEnvValue(value) {
|
|
@@ -182,7 +220,20 @@ async function buildAndPush(repository, entry) {
|
|
|
182
220
|
for (const [name, value] of Object.entries(docker.buildArgs ?? {})) {
|
|
183
221
|
args.push('--build-arg', `${name}="${expandEnvValue(value)}"`);
|
|
184
222
|
}
|
|
185
|
-
|
|
223
|
+
for (const [id, value] of Object.entries(docker.secrets ?? {}))
|
|
224
|
+
args.push('--secret', `"${secretSpec(pkg, id, value)}"`);
|
|
225
|
+
for (const [name, value] of Object.entries(docker.labels ?? {}))
|
|
226
|
+
args.push('--label', `"${name}=${value}"`);
|
|
227
|
+
if (docker.target)
|
|
228
|
+
args.push('--target', `"${docker.target}"`);
|
|
229
|
+
for (const from of asList(docker.cache?.from))
|
|
230
|
+
args.push('--cache-from', `"${from}"`);
|
|
231
|
+
for (const to of asList(docker.cache?.to))
|
|
232
|
+
args.push('--cache-to', `"${to}"`);
|
|
233
|
+
args.push('-f', `"${dockerfile}"`);
|
|
234
|
+
for (const tag of imageTags(pkg))
|
|
235
|
+
args.push('-t', `"${entry.image}:${tag}"`);
|
|
236
|
+
args.push('--push', '.');
|
|
186
237
|
/** **Shown as it runs and kept**, so a failure can say why. It ran with `stdio: 'inherit'`, and the
|
|
187
238
|
* one thing left to report was the exit code - measured on `panates/syncbridge`, the line that
|
|
188
239
|
* explained the failure sat forty lines above the plan's recap and the recap said
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PublishTarget } from '../../../core/interfaces/publish-target.js';
|
|
1
|
+
import type { PublishFilesKeys, PublishTarget } from '../../../core/interfaces/publish-target.js';
|
|
2
2
|
import type { ConfigValue, ScopedVars } from '../../../interfaces/rman-config.interface.js';
|
|
3
3
|
/**
|
|
4
4
|
* **`publish.docker.*`** - this target's own config block, declared here rather than centrally.
|
|
@@ -15,7 +15,7 @@ export interface DockerPublishOptions extends DockerPublishOptionsKeys, ScopedVa
|
|
|
15
15
|
/** Every key here is a **value**, so every one is a `ConfigValue` - this target declares no step,
|
|
16
16
|
* which is what makes the whole block uniform (see `VersionExtraKeys` for the interface where it
|
|
17
17
|
* is not). */
|
|
18
|
-
export interface DockerPublishOptionsKeys {
|
|
18
|
+
export interface DockerPublishOptionsKeys extends PublishFilesKeys {
|
|
19
19
|
/** DockerHub image name/repository - bare (e.g. `"my-app"`) to be prefixed with
|
|
20
20
|
* `--docker-namespace`/`DOCKERHUB_NAMESPACE`, or already-namespaced (contains a `/`) to use
|
|
21
21
|
* verbatim. */
|
|
@@ -38,6 +38,26 @@ export interface DockerPublishOptionsKeys {
|
|
|
38
38
|
/** A file (relative to the package's own directory) whose contents become the DockerHub repo's
|
|
39
39
|
* full description, if present. Default `"DOCKER_README.md"`. */
|
|
40
40
|
readme?: ConfigValue<string>;
|
|
41
|
+
/**
|
|
42
|
+
* `docker buildx build --secret` entries, keyed by the secret's id: a build-time value the image's
|
|
43
|
+
* layers never record, where a `buildArgs` value is visible in its history. `"$NAME"` takes it
|
|
44
|
+
* from `process.env.NAME`; anything else is a file, relative to the package. A Dockerfile reads
|
|
45
|
+
* one with `RUN --mount=type=secret,id=<id>`.
|
|
46
|
+
*/
|
|
47
|
+
secrets?: ConfigValue<Record<string, string>>;
|
|
48
|
+
/** Tags pushed besides the version's own and its floating one (`latest`, or a prerelease's
|
|
49
|
+
* identifier) - `"${{ semver.major(pkg.version) }}"` for a major line. */
|
|
50
|
+
tags?: ConfigValue<string[]>;
|
|
51
|
+
/** `docker buildx build --target` - the stage of a multi-stage Dockerfile that becomes the image. */
|
|
52
|
+
target?: ConfigValue<string>;
|
|
53
|
+
/** `--cache-from` / `--cache-to` - where the build reads and writes its layer cache
|
|
54
|
+
* (`type=gha` on GitHub Actions). Each a single value or a list. */
|
|
55
|
+
cache?: ConfigValue<{
|
|
56
|
+
from?: string | string[];
|
|
57
|
+
to?: string | string[];
|
|
58
|
+
}>;
|
|
59
|
+
/** `docker buildx build --label` entries - labels on the image beside the Dockerfile's own. */
|
|
60
|
+
labels?: ConfigValue<Record<string, string>>;
|
|
41
61
|
}
|
|
42
62
|
/**
|
|
43
63
|
* **Docker, as a publish target** - the core's own, and the reason `PublishTarget` is in the core
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import readline from 'node:readline/promises';
|
|
3
4
|
import colors from 'ansi-colors';
|
|
4
5
|
import { shipsTo, unknownTargets } from '../core/interfaces/publish-target.js';
|
|
5
6
|
import { registerCommand } from '../interfaces/rman-config.interface.js';
|
|
6
7
|
import { assertAllowedBranch, branchGuardOptions, readBranchGuardOptions } from '../utils/branch-guard.js';
|
|
8
|
+
import { UNCOMMITTED } from '../utils/git.js';
|
|
7
9
|
import { packageFilterOptions, readPackageFilterOptions } from '../utils/package-filter.js';
|
|
10
|
+
import { PublishFiles } from '../utils/publish-files.js';
|
|
8
11
|
/** Hoisted for `ArgsOf` - see `version.command.ts` and `RmanConfig.ArgsOf` for why. */
|
|
9
12
|
const COMMAND = 'publish';
|
|
10
13
|
/**
|
|
@@ -109,6 +112,12 @@ const publishCommand = registerCommand(app => {
|
|
|
109
112
|
],
|
|
110
113
|
handler: async (args) => {
|
|
111
114
|
await assertAllowedBranch(repository, readBranchGuardOptions(args));
|
|
115
|
+
/** Before anything is planned: a run that died while one target's files were in place left
|
|
116
|
+
* them there, and the next target - or the dirty check - must not see them. */
|
|
117
|
+
const recovered = PublishFiles.recover(repository.dirname);
|
|
118
|
+
if (recovered.length) {
|
|
119
|
+
console.error(colors.yellow(`Put back ${recovered.length} file(s) an interrupted publish had copied in (publish.<target>.files).`));
|
|
120
|
+
}
|
|
112
121
|
const requested = args.target;
|
|
113
122
|
/** A target built from what the others publish goes after them - see `publishesLast`. A
|
|
114
123
|
* stable sort, so registration order still decides among the rest. */
|
|
@@ -129,12 +138,27 @@ const publishCommand = registerCommand(app => {
|
|
|
129
138
|
for (const target of selected) {
|
|
130
139
|
plans.set(target, (await target.getPlan(ctx)).filter(entry => shipsTo(entry.package, target)));
|
|
131
140
|
}
|
|
141
|
+
/** A missing source fails the plan, before anything is published or copied. */
|
|
142
|
+
for (const [target, plan] of plans) {
|
|
143
|
+
for (const [i, entry] of plan.entries()) {
|
|
144
|
+
if (entry.status !== 'publish')
|
|
145
|
+
continue;
|
|
146
|
+
try {
|
|
147
|
+
filesFor(entry.package, target);
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
plan[i] = { ...entry, status: 'error', reason: e.message };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
132
154
|
if (args.json) {
|
|
133
155
|
console.log(JSON.stringify(jsonPlan(plans), undefined, 2));
|
|
134
156
|
}
|
|
135
157
|
else {
|
|
136
|
-
for (const [target, plan] of plans)
|
|
158
|
+
for (const [target, plan] of plans) {
|
|
137
159
|
printPlan(plan, target.name);
|
|
160
|
+
printFiles(plan, target);
|
|
161
|
+
}
|
|
138
162
|
}
|
|
139
163
|
/** An explicitly requested target that matched nothing is a mistake worth reporting: the run
|
|
140
164
|
* asked for something by name and silently got an empty plan back. */
|
|
@@ -147,7 +171,7 @@ const publishCommand = registerCommand(app => {
|
|
|
147
171
|
const all = [...plans.values()].flat();
|
|
148
172
|
const errors = all.filter(e => e.status === 'error');
|
|
149
173
|
if (errors.length) {
|
|
150
|
-
const allDirty = errors.every(e => e.reason
|
|
174
|
+
const allDirty = errors.every(e => e.reason?.startsWith(UNCOMMITTED));
|
|
151
175
|
throw logged(allDirty
|
|
152
176
|
? `${errors.length} package(s) have uncommitted local changes ` +
|
|
153
177
|
'(pass --ignore-dirty to exclude them instead of aborting)'
|
|
@@ -180,7 +204,15 @@ const publishCommand = registerCommand(app => {
|
|
|
180
204
|
waited = true;
|
|
181
205
|
await waitForPublished(ctx, publishedBy);
|
|
182
206
|
}
|
|
183
|
-
const
|
|
207
|
+
const files = plan.filter(e => e.status === 'publish').flatMap(e => filesFor(e.package, target));
|
|
208
|
+
const staged = files.length ? PublishFiles.stage(repository.dirname, `[${target.name}]`, files) : undefined;
|
|
209
|
+
let applied;
|
|
210
|
+
try {
|
|
211
|
+
applied = await target.applyPlan(ctx, plan);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
staged?.restore();
|
|
215
|
+
}
|
|
184
216
|
const done = printApplied(applied, plan, target.name);
|
|
185
217
|
outcomes.push(...done);
|
|
186
218
|
publishedBy.set(target, done.filter(o => o.entry.status === 'publish').map(o => o.entry));
|
|
@@ -257,6 +289,46 @@ function assertDeclaredTargetsExist(app, packages) {
|
|
|
257
289
|
}
|
|
258
290
|
/** One `--json` row per package *and target*: a package shipping to two registries needs two, or a
|
|
259
291
|
* consumer cannot tell which of them still has something to do. */
|
|
292
|
+
/**
|
|
293
|
+
* The files `publish.<target>.files` copies into `pkg` before that target publishes it - destination
|
|
294
|
+
* and source, both relative to the package. Throws naming the first source that does not exist, or
|
|
295
|
+
* a destination outside the package.
|
|
296
|
+
*/
|
|
297
|
+
/* **Copied, never written from config** - the user's call: a file a platform needs (a `config.json`,
|
|
298
|
+
* an image, a font) is kept in the repository as the file it is, with a diff and a history, and the
|
|
299
|
+
* config only says where it goes. Generic over targets because it is the same need for each. */
|
|
300
|
+
function filesFor(pkg, target) {
|
|
301
|
+
const block = pkg.config.publish?.[target.name];
|
|
302
|
+
const files = block && typeof block === 'object' ? block.files : undefined;
|
|
303
|
+
if (!files || typeof files !== 'object')
|
|
304
|
+
return [];
|
|
305
|
+
return Object.entries(files).map(([to, from]) => {
|
|
306
|
+
const where = `publish.${target.name}.files["${to}"]`;
|
|
307
|
+
if (typeof from !== 'string' || !from)
|
|
308
|
+
throw new Error(`${where} must name a file or directory to copy`);
|
|
309
|
+
const dest = path.resolve(pkg.dirname, to);
|
|
310
|
+
const source = path.resolve(pkg.dirname, from);
|
|
311
|
+
const relative = path.relative(pkg.dirname, dest);
|
|
312
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
313
|
+
throw new Error(`${where} must be a path inside the package`);
|
|
314
|
+
}
|
|
315
|
+
if (!fs.existsSync(source))
|
|
316
|
+
throw new Error(`${where}: ${from} does not exist`);
|
|
317
|
+
return { source, dest };
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
/** Under a target's plan, what each package to publish will have copied in - so `--dry-run` says
|
|
321
|
+
* it. */
|
|
322
|
+
function printFiles(plan, target) {
|
|
323
|
+
for (const entry of plan) {
|
|
324
|
+
if (entry.status !== 'publish')
|
|
325
|
+
continue;
|
|
326
|
+
for (const file of filesFor(entry.package, target)) {
|
|
327
|
+
const rel = (p) => path.relative(entry.package.dirname, p);
|
|
328
|
+
console.log(colors.gray(` ${entry.package.name}: ${rel(file.dest)} <- ${rel(file.source)}`));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
260
332
|
function jsonPlan(plans) {
|
|
261
333
|
return [...plans].flatMap(([target, plan]) => plan.map(entry => ({
|
|
262
334
|
name: entry.package.name,
|
package/constants.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '2.
|
|
1
|
+
export const version = '2.24.0';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RmanConfig } from '../../interfaces/rman-config.interface.js';
|
|
1
|
+
import type { ConfigValue, RmanConfig } from '../../interfaces/rman-config.interface.js';
|
|
2
2
|
import type { PackageFilterOptions } from '../../utils/package-filter.js';
|
|
3
3
|
import type { RmanApplication } from '../application.js';
|
|
4
4
|
import type { Package } from '../classes/package.js';
|
|
@@ -131,6 +131,19 @@ export declare namespace PublishTarget {
|
|
|
131
131
|
reason?: string;
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* `publish.<target>.files` - files copied into the package just before that target publishes it,
|
|
136
|
+
* and put back afterwards. Every target's block takes it; `publish` itself does the copying.
|
|
137
|
+
*/
|
|
138
|
+
export interface PublishFilesKeys {
|
|
139
|
+
/**
|
|
140
|
+
* Destination -> source, both relative to the package: `{ "dist/config.json":
|
|
141
|
+
* "deploy/cloudflare/config.json" }`. A source may be a file of any kind or a directory. What a
|
|
142
|
+
* destination held is moved aside and put back once the target is done, so each target sees only
|
|
143
|
+
* its own files.
|
|
144
|
+
*/
|
|
145
|
+
files?: ConfigValue<Record<string, string>>;
|
|
146
|
+
}
|
|
134
147
|
/**
|
|
135
148
|
* **A target's name, and deliberately not a union.**
|
|
136
149
|
*
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import fastGlob from 'fast-glob';
|
|
|
4
4
|
import { Package } from '../core/classes/package.js';
|
|
5
5
|
import { Repository } from '../core/classes/repository.js';
|
|
6
6
|
import { Service } from '../core/classes/service.js';
|
|
7
|
-
import { GitHelper } from '../utils/git.js';
|
|
7
|
+
import { dirtyReason, GitHelper } from '../utils/git.js';
|
|
8
8
|
import { expandReleaseTag, isCalendarVersion, releaseTagPattern } from '../utils/release-version.js';
|
|
9
9
|
import { ChangeHashService } from './change-hash.service.js';
|
|
10
10
|
/**
|
|
@@ -49,14 +49,15 @@ export class GithubReleaseService extends Service {
|
|
|
49
49
|
];
|
|
50
50
|
}
|
|
51
51
|
const tag = releaseTagFor(root);
|
|
52
|
-
|
|
52
|
+
const dirtyFiles = await git.listDirtyFiles();
|
|
53
|
+
if (dirtyFiles.length) {
|
|
53
54
|
return [
|
|
54
55
|
{
|
|
55
56
|
...base,
|
|
56
57
|
tag,
|
|
57
58
|
repository: repo,
|
|
58
59
|
status: options.ignoreDirty ? 'skip' : 'error',
|
|
59
|
-
reason:
|
|
60
|
+
reason: dirtyReason(dirtyFiles, repository.dirname),
|
|
60
61
|
},
|
|
61
62
|
];
|
|
62
63
|
}
|
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { RmanApplication } from '../core/application.js';
|
|
3
3
|
import { assertOneScheme, semverScheme, VersionScheme } from '../core/classes/version-scheme.js';
|
|
4
4
|
import { skipReasonFor, targetsOf } from '../core/interfaces/publish-target.js';
|
|
5
|
-
import { GitHelper } from '../utils/git.js';
|
|
5
|
+
import { dirtyReason, filesUnder, GitHelper } from '../utils/git.js';
|
|
6
6
|
import { filterPackages } from '../utils/package-filter.js';
|
|
7
7
|
import { findLastReleaseVersion, formatCalendarVersion, usesCalendarVersion } from '../utils/release-version.js';
|
|
8
8
|
import { groupKeyOf, ROOT_GROUP_KEY } from '../utils/version-group.js';
|
|
@@ -98,7 +98,8 @@ export class VersionPlanService {
|
|
|
98
98
|
const git = new GitHelper({ cwd: repository.dirname });
|
|
99
99
|
const packages = filterPackages(repository.getPackages(), options);
|
|
100
100
|
const dirtyFiles = await git.listDirtyFiles({ absolute: true });
|
|
101
|
-
const isDirty = (pkg) => dirtyFiles
|
|
101
|
+
const isDirty = (pkg) => filesUnder(dirtyFiles, pkg.dirname).length > 0;
|
|
102
|
+
const dirtyReasonOf = (pkg) => dirtyReason(filesUnder(dirtyFiles, pkg.dirname), repository.dirname);
|
|
102
103
|
const dirty = packages.filter(isDirty);
|
|
103
104
|
const entries = new Map();
|
|
104
105
|
/**
|
|
@@ -149,7 +150,7 @@ export class VersionPlanService {
|
|
|
149
150
|
group: this.groupLabel(this.resolveGroupKey(pkg)),
|
|
150
151
|
status: 'skip',
|
|
151
152
|
from: pkg.version,
|
|
152
|
-
reason:
|
|
153
|
+
reason: dirtyReasonOf(pkg),
|
|
153
154
|
});
|
|
154
155
|
}
|
|
155
156
|
const commitMessage = repository.rootPackage.config?.version?.commitMessage;
|
|
@@ -209,7 +210,7 @@ export class VersionPlanService {
|
|
|
209
210
|
if (!entry || unversioned.has(pkg))
|
|
210
211
|
continue;
|
|
211
212
|
entry.status = 'error';
|
|
212
|
-
entry.reason = entry.reason ?
|
|
213
|
+
entry.reason = entry.reason ? `${dirtyReasonOf(pkg)} (${entry.reason})` : dirtyReasonOf(pkg);
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
return result;
|
package/utils/git.d.ts
CHANGED
|
@@ -150,3 +150,13 @@ export declare namespace GitHelper {
|
|
|
150
150
|
* show the total while the count is still zero. */
|
|
151
151
|
type CommitProgress = (done: number, total: number) => void;
|
|
152
152
|
}
|
|
153
|
+
/** What every "you have uncommitted changes" refusal starts with - compared against by callers, so
|
|
154
|
+
* it lives once. */
|
|
155
|
+
export declare const UNCOMMITTED = "uncommitted local changes";
|
|
156
|
+
/**
|
|
157
|
+
* `uncommitted local changes: a, b, c (+4 more)` - the refusal with the files that cause it,
|
|
158
|
+
* relative to `root`, at most three of them.
|
|
159
|
+
*/
|
|
160
|
+
export declare function dirtyReason(files: readonly string[], root: string): string;
|
|
161
|
+
/** The files in `files` that sit inside `dir` - both absolute. */
|
|
162
|
+
export declare function filesUnder(files: readonly string[], dir: string): string[];
|
package/utils/git.js
CHANGED
|
@@ -479,3 +479,24 @@ const execFileP = promisify(execFile);
|
|
|
479
479
|
function execFileAsync(file, args, options) {
|
|
480
480
|
return execFileP(file, args, { maxBuffer: GIT_MAX_BUFFER, ...options });
|
|
481
481
|
}
|
|
482
|
+
/** What every "you have uncommitted changes" refusal starts with - compared against by callers, so
|
|
483
|
+
* it lives once. */
|
|
484
|
+
export const UNCOMMITTED = 'uncommitted local changes';
|
|
485
|
+
/**
|
|
486
|
+
* `uncommitted local changes: a, b, c (+4 more)` - the refusal with the files that cause it,
|
|
487
|
+
* relative to `root`, at most three of them.
|
|
488
|
+
*/
|
|
489
|
+
/* **Named, because the bare sentence cost a release.** On `panates/syncbridge` a build rewrote a
|
|
490
|
+
* Dockerfile label, `rman github-release` answered only `uncommitted local changes`, and the file was
|
|
491
|
+
* found by reading every build script in the repository - the CI log had nothing else to say. */
|
|
492
|
+
export function dirtyReason(files, root) {
|
|
493
|
+
if (!files.length)
|
|
494
|
+
return UNCOMMITTED;
|
|
495
|
+
const shown = files.slice(0, 3).map(f => (path.isAbsolute(f) ? path.relative(root, f) : f));
|
|
496
|
+
const more = files.length > shown.length ? ` (+${files.length - shown.length} more)` : '';
|
|
497
|
+
return `${UNCOMMITTED}: ${shown.join(', ')}${more}`;
|
|
498
|
+
}
|
|
499
|
+
/** The files in `files` that sit inside `dir` - both absolute. */
|
|
500
|
+
export function filesUnder(files, dir) {
|
|
501
|
+
return files.filter(f => !path.relative(dir, f).startsWith('..'));
|
|
502
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files copied into a package for one publish target, and put back afterwards - see
|
|
3
|
+
* `publish.<target>.files`.
|
|
4
|
+
*
|
|
5
|
+
* `stage` writes a journal, moves aside whatever each destination held, and copies the sources in;
|
|
6
|
+
* `restore` removes the copies and moves the originals back. A run that dies between the two leaves
|
|
7
|
+
* the journal behind, and `recover` - called before the next publish plans anything - finishes the
|
|
8
|
+
* restore, so one target's files never reach another target's publish.
|
|
9
|
+
*/
|
|
10
|
+
export declare class PublishFiles {
|
|
11
|
+
protected readonly dir: string;
|
|
12
|
+
protected readonly journal: PublishFiles.Journal;
|
|
13
|
+
private _done;
|
|
14
|
+
private _unhook?;
|
|
15
|
+
protected constructor(dir: string, journal: PublishFiles.Journal);
|
|
16
|
+
/**
|
|
17
|
+
* Copies `files` in, after moving aside whatever their destinations held, and returns the handle
|
|
18
|
+
* that puts everything back. `label` names the run in the journal - `[docker] my-app`.
|
|
19
|
+
*/
|
|
20
|
+
static stage(repositoryDir: string, label: string, files: readonly PublishFiles.File[]): PublishFiles;
|
|
21
|
+
/**
|
|
22
|
+
* Finishes the restore a previous run left unfinished, and returns the destinations it put back -
|
|
23
|
+
* empty when there was nothing to do.
|
|
24
|
+
*/
|
|
25
|
+
static recover(repositoryDir: string): string[];
|
|
26
|
+
/** Where the journal and the moved-aside originals live for this repository. */
|
|
27
|
+
static stateDir(repositoryDir: string): string;
|
|
28
|
+
/** Removes the copies, moves the originals back, drops the directories staging created, and the
|
|
29
|
+
* journal last. Safe to call twice. */
|
|
30
|
+
restore(): void;
|
|
31
|
+
protected writeJournal(): void;
|
|
32
|
+
}
|
|
33
|
+
export declare namespace PublishFiles {
|
|
34
|
+
/** One copy: `source` into `dest`, both absolute. */
|
|
35
|
+
interface File {
|
|
36
|
+
source: string;
|
|
37
|
+
dest: string;
|
|
38
|
+
}
|
|
39
|
+
interface JournalEntry {
|
|
40
|
+
dest: string;
|
|
41
|
+
/** Where the destination's previous content was moved, when it had any. */
|
|
42
|
+
backup?: string;
|
|
43
|
+
/** Directories that did not exist and were created to hold `dest`, outermost first. */
|
|
44
|
+
createdDirs: string[];
|
|
45
|
+
}
|
|
46
|
+
interface Journal {
|
|
47
|
+
label: string;
|
|
48
|
+
entries: JournalEntry[];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { onExit } from 'signal-exit';
|
|
6
|
+
/**
|
|
7
|
+
* Files copied into a package for one publish target, and put back afterwards - see
|
|
8
|
+
* `publish.<target>.files`.
|
|
9
|
+
*
|
|
10
|
+
* `stage` writes a journal, moves aside whatever each destination held, and copies the sources in;
|
|
11
|
+
* `restore` removes the copies and moves the originals back. A run that dies between the two leaves
|
|
12
|
+
* the journal behind, and `recover` - called before the next publish plans anything - finishes the
|
|
13
|
+
* restore, so one target's files never reach another target's publish.
|
|
14
|
+
*/
|
|
15
|
+
/* **Kept in `.git/rman/publish-files`**, the user's choice and for three reasons: it is inside the
|
|
16
|
+
* repository, so the next run finds it; git never commits it, so it needs no `.gitignore`; and it
|
|
17
|
+
* survives a reboot, which `os.tmpdir()` may not - and a reboot is the case the journal exists for.
|
|
18
|
+
* A directory that is not a git repository falls back to the temporary directory.
|
|
19
|
+
*
|
|
20
|
+
* **Moved aside, not copied.** A rename on one filesystem is instant whatever the size - an image,
|
|
21
|
+
* a whole directory - and the original is untouched until it is moved back. Across filesystems it
|
|
22
|
+
* copies and removes instead.
|
|
23
|
+
*
|
|
24
|
+
* **The journal is written before anything is touched**, listing every destination and whether it
|
|
25
|
+
* held something, so a run killed at any point leaves enough to undo exactly what it did.
|
|
26
|
+
*
|
|
27
|
+
* Restoring is synchronous, so it can run from `signal-exit`'s handler on Ctrl-C or a terminated
|
|
28
|
+
* CI job - the same hook `child-tracker` uses to kill children. */
|
|
29
|
+
export class PublishFiles {
|
|
30
|
+
dir;
|
|
31
|
+
journal;
|
|
32
|
+
_done = false;
|
|
33
|
+
_unhook;
|
|
34
|
+
constructor(dir, journal) {
|
|
35
|
+
this.dir = dir;
|
|
36
|
+
this.journal = journal;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Copies `files` in, after moving aside whatever their destinations held, and returns the handle
|
|
40
|
+
* that puts everything back. `label` names the run in the journal - `[docker] my-app`.
|
|
41
|
+
*/
|
|
42
|
+
static stage(repositoryDir, label, files) {
|
|
43
|
+
const dir = PublishFiles.stateDir(repositoryDir);
|
|
44
|
+
fs.mkdirSync(path.join(dir, 'backup'), { recursive: true });
|
|
45
|
+
const entries = files.map((file, i) => {
|
|
46
|
+
const existed = lexists(file.dest);
|
|
47
|
+
return {
|
|
48
|
+
dest: file.dest,
|
|
49
|
+
backup: existed ? path.join(dir, 'backup', String(i)) : undefined,
|
|
50
|
+
createdDirs: missingParents(file.dest),
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
const staged = new this(dir, { label, entries });
|
|
54
|
+
staged.writeJournal();
|
|
55
|
+
staged._unhook = onExit(() => staged.restore());
|
|
56
|
+
for (const [i, entry] of entries.entries()) {
|
|
57
|
+
if (entry.backup)
|
|
58
|
+
move(entry.dest, entry.backup);
|
|
59
|
+
fs.mkdirSync(path.dirname(entry.dest), { recursive: true });
|
|
60
|
+
fs.cpSync(files[i].source, entry.dest, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
return staged;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Finishes the restore a previous run left unfinished, and returns the destinations it put back -
|
|
66
|
+
* empty when there was nothing to do.
|
|
67
|
+
*/
|
|
68
|
+
static recover(repositoryDir) {
|
|
69
|
+
const dir = PublishFiles.stateDir(repositoryDir);
|
|
70
|
+
const file = path.join(dir, JOURNAL);
|
|
71
|
+
if (!fs.existsSync(file))
|
|
72
|
+
return [];
|
|
73
|
+
const journal = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
74
|
+
new this(dir, journal).restore();
|
|
75
|
+
return journal.entries.map(e => e.dest);
|
|
76
|
+
}
|
|
77
|
+
/** Where the journal and the moved-aside originals live for this repository. */
|
|
78
|
+
static stateDir(repositoryDir) {
|
|
79
|
+
try {
|
|
80
|
+
const gitDir = execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
|
|
81
|
+
cwd: repositoryDir,
|
|
82
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
83
|
+
})
|
|
84
|
+
.toString()
|
|
85
|
+
.trim();
|
|
86
|
+
return path.join(gitDir, 'rman', 'publish-files');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return path.join(os.tmpdir(), 'rman-publish-files', Buffer.from(repositoryDir).toString('base64url'));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Removes the copies, moves the originals back, drops the directories staging created, and the
|
|
93
|
+
* journal last. Safe to call twice. */
|
|
94
|
+
restore() {
|
|
95
|
+
if (this._done)
|
|
96
|
+
return;
|
|
97
|
+
this._done = true;
|
|
98
|
+
this._unhook?.();
|
|
99
|
+
for (const entry of [...this.journal.entries].reverse()) {
|
|
100
|
+
fs.rmSync(entry.dest, { recursive: true, force: true });
|
|
101
|
+
if (entry.backup && lexists(entry.backup)) {
|
|
102
|
+
fs.mkdirSync(path.dirname(entry.dest), { recursive: true });
|
|
103
|
+
move(entry.backup, entry.dest);
|
|
104
|
+
}
|
|
105
|
+
for (const created of [...entry.createdDirs].reverse()) {
|
|
106
|
+
if (fs.existsSync(created) && !fs.readdirSync(created).length)
|
|
107
|
+
fs.rmdirSync(created);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
fs.rmSync(this.dir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
writeJournal() {
|
|
113
|
+
fs.writeFileSync(path.join(this.dir, JOURNAL), JSON.stringify(this.journal, undefined, 2));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const JOURNAL = 'journal.json';
|
|
117
|
+
/** Whether anything is at `p`, a broken symbolic link included. */
|
|
118
|
+
function lexists(p) {
|
|
119
|
+
try {
|
|
120
|
+
fs.lstatSync(p);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** The directories above `file` that do not exist yet, outermost first. */
|
|
128
|
+
function missingParents(file) {
|
|
129
|
+
const missing = [];
|
|
130
|
+
let dir = path.dirname(file);
|
|
131
|
+
while (!fs.existsSync(dir) && dir !== path.dirname(dir)) {
|
|
132
|
+
missing.unshift(dir);
|
|
133
|
+
dir = path.dirname(dir);
|
|
134
|
+
}
|
|
135
|
+
return missing;
|
|
136
|
+
}
|
|
137
|
+
/** `rename`, or copy and remove where the two paths are on different filesystems. */
|
|
138
|
+
function move(from, to) {
|
|
139
|
+
try {
|
|
140
|
+
fs.renameSync(from, to);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
if (e?.code !== 'EXDEV')
|
|
144
|
+
throw e;
|
|
145
|
+
fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true });
|
|
146
|
+
fs.rmSync(from, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|