release-skill 0.2.9 → 0.3.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +21 -0
- package/INSTALL.md +4 -4
- package/INSTALL.zh-CN.md +4 -4
- package/README.md +38 -7
- package/README.zh-CN.md +36 -7
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +3908 -2864
- package/adapters/claude/skills/release-help/SKILL.md +9 -1
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +3908 -2864
- package/adapters/codex/skills/release-help/SKILL.md +9 -1
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +3908 -2864
- package/adapters/kimi/skills/release-help/SKILL.md +9 -1
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +3908 -2864
- package/adapters/workbuddy/skills/release-help/SKILL.md +9 -1
- package/bin/release-skill-cli.mjs +176 -4
- package/bin/release-skill.bundle.mjs +3908 -2864
- package/package.json +1 -1
- package/skills/release-help/SKILL.md +9 -1
- package/skills-src/release-help/SKILL.md +9 -1
- package/src/adapters/plugin-marketplace.mjs +1 -1
- package/src/adapters/push-snapshot.mjs +3 -1
- package/src/commands/attest.mjs +195 -0
- package/src/commands/hooks.mjs +46 -0
- package/src/commands/ship.mjs +356 -0
- package/src/commands/verify.mjs +147 -4
- package/src/core/git-transport.mjs +93 -0
- package/src/core/release-metadata.mjs +105 -0
package/src/commands/verify.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
GATE_FAILED,
|
|
44
44
|
CONFIG_MISSING,
|
|
45
45
|
POST_PUBLISH_VERIFY_FAILED,
|
|
46
|
+
CONSUMER_VERIFICATION_DEFERRED,
|
|
46
47
|
} from '../core/errors.mjs';
|
|
47
48
|
import { verifySourceAuthorityReceipt } from '../core/source-authority.mjs';
|
|
48
49
|
import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
|
|
@@ -67,6 +68,20 @@ import {
|
|
|
67
68
|
buildDirectoryFileIndex,
|
|
68
69
|
checkNpmEntryClosure,
|
|
69
70
|
} from '../npm/npm-entry-closure.mjs';
|
|
71
|
+
import {
|
|
72
|
+
KIMI_ATTESTATION_FILE,
|
|
73
|
+
KIMI_REQUIREMENT_FILE,
|
|
74
|
+
kimiAuthorityDir,
|
|
75
|
+
resolveBoundPlanDigest,
|
|
76
|
+
validateKimiAttestation,
|
|
77
|
+
} from '../platforms/kimi.mjs';
|
|
78
|
+
import {
|
|
79
|
+
CODEBUDDY_ATTESTATION_FILE,
|
|
80
|
+
CODEBUDDY_REQUIREMENT_FILE,
|
|
81
|
+
codebuddyAuthorityDir,
|
|
82
|
+
resolveCodeBuddyBoundPlanDigest,
|
|
83
|
+
validateCodeBuddyAttestation,
|
|
84
|
+
} from '../platforms/codebuddy.mjs';
|
|
70
85
|
|
|
71
86
|
// ---------------------------------------------------------------------------
|
|
72
87
|
// Constants
|
|
@@ -120,6 +135,103 @@ function isValidDigest(digest) {
|
|
|
120
135
|
return typeof digest === 'string' && /^[a-f0-9]{64}$/.test(digest);
|
|
121
136
|
}
|
|
122
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Generate every manual consumer requirement before any expensive automatic
|
|
140
|
+
* consumer install starts. This turns a sequence of "install Kimi, rerun,
|
|
141
|
+
* then discover CodeBuddy" failures into one actionable response.
|
|
142
|
+
*/
|
|
143
|
+
async function collectMissingManualAttestations({
|
|
144
|
+
actions,
|
|
145
|
+
adapterRegistry,
|
|
146
|
+
plan,
|
|
147
|
+
root,
|
|
148
|
+
runDir,
|
|
149
|
+
clockFn,
|
|
150
|
+
}) {
|
|
151
|
+
const manual = actions.filter((action) => (
|
|
152
|
+
action.type === 'kimi-marketplace-install'
|
|
153
|
+
|| action.type === 'codebuddy-marketplace-install'
|
|
154
|
+
));
|
|
155
|
+
if (manual.length === 0) return [];
|
|
156
|
+
|
|
157
|
+
const missing = [];
|
|
158
|
+
for (const action of manual) {
|
|
159
|
+
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
160
|
+
const adapter = adapterRegistry.getAdapter(adapterActionType);
|
|
161
|
+
// Tests and embedders may intentionally register a fully automatic
|
|
162
|
+
// adapter for these action types. Only the real platform adapter owns the
|
|
163
|
+
// interactive manual-install protocol and its stable authority files.
|
|
164
|
+
if (adapter.name !== 'plugin-marketplace') continue;
|
|
165
|
+
const context = {
|
|
166
|
+
externalWritesAuthorized: false,
|
|
167
|
+
isolatedConsumerWritesAuthorized: true,
|
|
168
|
+
plan,
|
|
169
|
+
baseline: plan.baseline,
|
|
170
|
+
root,
|
|
171
|
+
runDir,
|
|
172
|
+
};
|
|
173
|
+
const actionInput = { actionType: adapterActionType, ...action.parameters };
|
|
174
|
+
const preflight = await adapter.preflight(actionInput, context);
|
|
175
|
+
if (preflight.status !== 'PREFLIGHT_PASSED') {
|
|
176
|
+
throw new ReleaseError(
|
|
177
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
178
|
+
`manual consumer preflight did not pass for action "${action.id}": ${preflight.error}`,
|
|
179
|
+
{ actionId: action.id, platform: action.type.split('-')[0] },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const executed = await adapter.execute(actionInput, context);
|
|
183
|
+
if (executed.status !== 'EXECUTED') {
|
|
184
|
+
throw new ReleaseError(
|
|
185
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
186
|
+
`cannot generate manual consumer requirement for action "${action.id}": ${executed.error}`,
|
|
187
|
+
{ actionId: action.id, platform: action.type.split('-')[0] },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const isKimi = action.type === 'kimi-marketplace-install';
|
|
192
|
+
const planDigest = isKimi
|
|
193
|
+
? resolveBoundPlanDigest(context)
|
|
194
|
+
: await resolveCodeBuddyBoundPlanDigest(context);
|
|
195
|
+
const authorityDir = isKimi
|
|
196
|
+
? kimiAuthorityDir(context, planDigest, action.parameters.plugin)
|
|
197
|
+
: codebuddyAuthorityDir(context, planDigest, action.parameters.plugin);
|
|
198
|
+
const requirementPath = join(
|
|
199
|
+
authorityDir,
|
|
200
|
+
isKimi ? KIMI_REQUIREMENT_FILE : CODEBUDDY_REQUIREMENT_FILE,
|
|
201
|
+
);
|
|
202
|
+
const attestationPath = join(
|
|
203
|
+
authorityDir,
|
|
204
|
+
isKimi ? KIMI_ATTESTATION_FILE : CODEBUDDY_ATTESTATION_FILE,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
let valid = false;
|
|
208
|
+
let reason = 'attestation file is missing';
|
|
209
|
+
try {
|
|
210
|
+
const attestation = JSON.parse(await readFile(attestationPath, 'utf8'));
|
|
211
|
+
const validation = isKimi
|
|
212
|
+
? validateKimiAttestation(attestation, action.parameters, clockFn(), planDigest)
|
|
213
|
+
: validateCodeBuddyAttestation(attestation, action.parameters, clockFn(), planDigest);
|
|
214
|
+
valid = validation.valid;
|
|
215
|
+
reason = validation.error;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error?.code !== 'ENOENT') reason = `attestation cannot be read: ${error.message}`;
|
|
218
|
+
}
|
|
219
|
+
if (!valid) {
|
|
220
|
+
missing.push({
|
|
221
|
+
actionId: action.id,
|
|
222
|
+
platform: isKimi ? 'kimi' : 'codebuddy',
|
|
223
|
+
plugin: action.parameters.plugin,
|
|
224
|
+
version: action.parameters.version,
|
|
225
|
+
planDigest,
|
|
226
|
+
requirementPath,
|
|
227
|
+
attestationPath,
|
|
228
|
+
reason,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return missing;
|
|
233
|
+
}
|
|
234
|
+
|
|
123
235
|
// ---------------------------------------------------------------------------
|
|
124
236
|
// Smoke test
|
|
125
237
|
// ---------------------------------------------------------------------------
|
|
@@ -1000,7 +1112,33 @@ export async function verifyRelease(options) {
|
|
|
1000
1112
|
}
|
|
1001
1113
|
}
|
|
1002
1114
|
|
|
1003
|
-
|
|
1115
|
+
const missingManualAttestations = await collectMissingManualAttestations({
|
|
1116
|
+
actions,
|
|
1117
|
+
adapterRegistry,
|
|
1118
|
+
plan,
|
|
1119
|
+
root,
|
|
1120
|
+
runDir,
|
|
1121
|
+
clockFn,
|
|
1122
|
+
});
|
|
1123
|
+
if (missingManualAttestations.length > 0) {
|
|
1124
|
+
await evidence.append({
|
|
1125
|
+
phase: 'verify',
|
|
1126
|
+
step: 'manual-attestations',
|
|
1127
|
+
status: 'needs-input',
|
|
1128
|
+
requirements: missingManualAttestations,
|
|
1129
|
+
});
|
|
1130
|
+
throw new ReleaseError(
|
|
1131
|
+
CONSUMER_VERIFICATION_DEFERRED,
|
|
1132
|
+
`${missingManualAttestations.length} manual consumer attestation(s) are required`,
|
|
1133
|
+
{ requirements: missingManualAttestations },
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Every action is identity-bound and adapters receive read-only or
|
|
1138
|
+
// per-action isolated consumer paths. Run independent checks concurrently;
|
|
1139
|
+
// the evidence writer serializes append operations and result arrays are
|
|
1140
|
+
// sorted afterwards for deterministic receipts.
|
|
1141
|
+
const actionResults = await Promise.allSettled(actions.map(async (action) => {
|
|
1004
1142
|
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
1005
1143
|
|
|
1006
1144
|
// Skip meta-checkpoints
|
|
@@ -1011,7 +1149,7 @@ export async function verifyRelease(options) {
|
|
|
1011
1149
|
status: 'SKIPPED',
|
|
1012
1150
|
reason: 'meta-checkpoint',
|
|
1013
1151
|
});
|
|
1014
|
-
|
|
1152
|
+
return;
|
|
1015
1153
|
}
|
|
1016
1154
|
|
|
1017
1155
|
let adapter;
|
|
@@ -1142,7 +1280,7 @@ export async function verifyRelease(options) {
|
|
|
1142
1280
|
installationContractDigest: currentDigest,
|
|
1143
1281
|
});
|
|
1144
1282
|
|
|
1145
|
-
|
|
1283
|
+
return;
|
|
1146
1284
|
}
|
|
1147
1285
|
}
|
|
1148
1286
|
|
|
@@ -1305,7 +1443,12 @@ export async function verifyRelease(options) {
|
|
|
1305
1443
|
);
|
|
1306
1444
|
}
|
|
1307
1445
|
}
|
|
1308
|
-
}
|
|
1446
|
+
}));
|
|
1447
|
+
adapterChecks.sort((a, b) => a.actionId.localeCompare(b.actionId));
|
|
1448
|
+
consumerVerificationReceipts.sort((a, b) => a.actionId.localeCompare(b.actionId));
|
|
1449
|
+
consumerGateResults.sort((a, b) => a.id.localeCompare(b.id));
|
|
1450
|
+
const rejectedAction = actionResults.find((result) => result.status === 'rejected');
|
|
1451
|
+
if (rejectedAction) throw rejectedAction.reason;
|
|
1309
1452
|
|
|
1310
1453
|
await evidence.append({ phase: 'verify', step: 'adapter-verify', status: 'completed' });
|
|
1311
1454
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
import { ReleaseError, REMOTE_UNAVAILABLE, REMOTE_CONFLICT } from './errors.mjs';
|
|
5
|
+
|
|
6
|
+
const execFile = promisify(execFileCb);
|
|
7
|
+
|
|
8
|
+
function urls(repo, host) {
|
|
9
|
+
if (typeof repo !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) {
|
|
10
|
+
throw new ReleaseError(REMOTE_UNAVAILABLE, 'git transport preflight requires owner/name repositories');
|
|
11
|
+
}
|
|
12
|
+
const safeHost = host ?? 'github.com';
|
|
13
|
+
if (!/^[A-Za-z0-9.-]+$/.test(safeHost)) {
|
|
14
|
+
throw new ReleaseError(REMOTE_UNAVAILABLE, 'git transport preflight requires a safe GitHub hostname');
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
https: `https://${safeHost}/${repo}.git`,
|
|
18
|
+
ssh: `git@${safeHost}:${repo}.git`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function identity(stdout) {
|
|
23
|
+
const lines = String(stdout).trim().split('\n').filter(Boolean);
|
|
24
|
+
const head = lines.find((line) => /^[a-f0-9]{40,64}\s+HEAD$/.test(line.trim()));
|
|
25
|
+
const symref = lines.find((line) => line.startsWith('ref: '));
|
|
26
|
+
return `${symref ?? ''}\n${head ?? ''}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function preflightGitTransports(plan, options = {}) {
|
|
30
|
+
const exec = options.exec ?? ((command, args, execOptions) => execFile(command, args, execOptions));
|
|
31
|
+
const repositories = new Map();
|
|
32
|
+
for (const action of plan.externalActions ?? []) {
|
|
33
|
+
const repo = action.parameters?.repo;
|
|
34
|
+
if (!repo) continue;
|
|
35
|
+
const host = action.parameters?.githubHost ?? 'github.com';
|
|
36
|
+
repositories.set(`${host}/${repo}`, { repo, host });
|
|
37
|
+
}
|
|
38
|
+
const observations = [];
|
|
39
|
+
for (const { repo, host } of repositories.values()) {
|
|
40
|
+
const candidates = urls(repo, host);
|
|
41
|
+
const observation = { repo, host };
|
|
42
|
+
for (const transport of ['https', 'ssh']) {
|
|
43
|
+
try {
|
|
44
|
+
const { stdout } = await exec(
|
|
45
|
+
'git',
|
|
46
|
+
['ls-remote', '--symref', candidates[transport], 'HEAD'],
|
|
47
|
+
{ shell: false, encoding: 'utf8', timeout: 30_000 },
|
|
48
|
+
);
|
|
49
|
+
observation[transport] = {
|
|
50
|
+
status: 'available',
|
|
51
|
+
identity: identity(stdout),
|
|
52
|
+
};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
observation[transport] = {
|
|
55
|
+
status: 'unavailable',
|
|
56
|
+
error: error.message,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (observation.https.status === 'unavailable' && observation.ssh.status === 'unavailable') {
|
|
61
|
+
throw new ReleaseError(
|
|
62
|
+
REMOTE_UNAVAILABLE,
|
|
63
|
+
`neither HTTPS nor SSH can read ${repo}`,
|
|
64
|
+
{ repository: repo, transports: observation },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
observation.https.status === 'available'
|
|
69
|
+
&& observation.ssh.status === 'available'
|
|
70
|
+
&& observation.https.identity !== observation.ssh.identity
|
|
71
|
+
) {
|
|
72
|
+
throw new ReleaseError(
|
|
73
|
+
REMOTE_CONFLICT,
|
|
74
|
+
`HTTPS and SSH resolve different remote identities for ${repo}`,
|
|
75
|
+
{ repository: repo },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
observations.push(observation);
|
|
79
|
+
}
|
|
80
|
+
const transport = observations.some((entry) => entry.https.status === 'unavailable')
|
|
81
|
+
? 'ssh'
|
|
82
|
+
: 'https';
|
|
83
|
+
if (
|
|
84
|
+
transport === 'ssh'
|
|
85
|
+
&& observations.some((entry) => entry.ssh.status !== 'available')
|
|
86
|
+
) {
|
|
87
|
+
throw new ReleaseError(
|
|
88
|
+
REMOTE_UNAVAILABLE,
|
|
89
|
+
'no single safe Git transport is available for every release repository',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return { transport, repositories: observations };
|
|
93
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
|
|
5
|
+
import { ReleaseError, CONFIG_INVALID, GATE_FAILED } from './errors.mjs';
|
|
6
|
+
|
|
7
|
+
function assertOid(value, label) {
|
|
8
|
+
if (typeof value !== 'string' || !/^[a-f0-9]{40,64}$/.test(value)) {
|
|
9
|
+
throw new ReleaseError(GATE_FAILED, `${label} is not a full Git object id`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* After VERIFIED, advance the project-only previous-public baseline to the
|
|
15
|
+
* exact public commit already frozen, published and verified. This prepares
|
|
16
|
+
* the next release without asking maintainers to copy commits from terminal
|
|
17
|
+
* output. It never edits public files or performs remote writes.
|
|
18
|
+
*/
|
|
19
|
+
export async function updatePreviousPublicBaselines(options = {}) {
|
|
20
|
+
const root = resolve(options.root ?? process.cwd());
|
|
21
|
+
const planPath = resolve(options.planPath ?? '');
|
|
22
|
+
const configPath = resolve(root, '.release-skill', 'project.yaml');
|
|
23
|
+
let plan;
|
|
24
|
+
let configRaw;
|
|
25
|
+
let configStat;
|
|
26
|
+
try {
|
|
27
|
+
[plan, configRaw, configStat] = await Promise.all([
|
|
28
|
+
readFile(planPath, 'utf8').then(JSON.parse),
|
|
29
|
+
readFile(configPath, 'utf8'),
|
|
30
|
+
lstat(configPath),
|
|
31
|
+
]);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new ReleaseError(
|
|
34
|
+
CONFIG_INVALID,
|
|
35
|
+
`cannot load release metadata authorities: ${error.message}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (!configStat.isFile() || configStat.isSymbolicLink()) {
|
|
39
|
+
throw new ReleaseError(CONFIG_INVALID, 'project config must be a regular non-symlink file');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const doc = YAML.parseDocument(configRaw, { uniqueKeys: true });
|
|
43
|
+
if (doc.errors.length > 0) {
|
|
44
|
+
throw new ReleaseError(CONFIG_INVALID, `project config YAML is invalid: ${doc.errors[0].message}`);
|
|
45
|
+
}
|
|
46
|
+
const releaseUnits = doc.get('releaseUnits', true);
|
|
47
|
+
if (!YAML.isSeq(releaseUnits)) {
|
|
48
|
+
throw new ReleaseError(CONFIG_INVALID, 'project config releaseUnits must be a sequence');
|
|
49
|
+
}
|
|
50
|
+
const configUnits = new Map();
|
|
51
|
+
for (const node of releaseUnits.items) {
|
|
52
|
+
const id = node?.get?.('id');
|
|
53
|
+
if (typeof id === 'string') configUnits.set(id, node);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const updates = [];
|
|
57
|
+
for (const unit of plan.units ?? []) {
|
|
58
|
+
const configUnit = configUnits.get(unit.id);
|
|
59
|
+
if (!configUnit) {
|
|
60
|
+
throw new ReleaseError(CONFIG_INVALID, `frozen unit "${unit.id}" is absent from project config`);
|
|
61
|
+
}
|
|
62
|
+
const frozen = unit.frozenSnapshot;
|
|
63
|
+
assertOid(frozen?.commit, `unit "${unit.id}" frozen commit`);
|
|
64
|
+
assertOid(frozen?.tree, `unit "${unit.id}" frozen tree`);
|
|
65
|
+
if (!/^[a-f0-9]{64}$/.test(frozen?.manifestDigest ?? '')) {
|
|
66
|
+
throw new ReleaseError(GATE_FAILED, `unit "${unit.id}" frozen manifest digest is invalid`);
|
|
67
|
+
}
|
|
68
|
+
const old = configUnit.get('previousPublicBaseline', true)?.toJSON?.() ?? {};
|
|
69
|
+
const next = {
|
|
70
|
+
mode: 'bound',
|
|
71
|
+
repo: old.repo ?? unit.publicRepo,
|
|
72
|
+
ref: old.ref ?? `refs/heads/${frozen.branch}`,
|
|
73
|
+
commit: frozen.commit,
|
|
74
|
+
tree: frozen.tree,
|
|
75
|
+
manifestDigest: frozen.manifestDigest,
|
|
76
|
+
};
|
|
77
|
+
if (!next.repo || !next.ref) {
|
|
78
|
+
throw new ReleaseError(GATE_FAILED, `unit "${unit.id}" cannot derive its next public baseline identity`);
|
|
79
|
+
}
|
|
80
|
+
configUnit.set('previousPublicBaseline', next);
|
|
81
|
+
updates.push({
|
|
82
|
+
id: unit.id,
|
|
83
|
+
previousCommit: old.commit ?? null,
|
|
84
|
+
commit: frozen.commit,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const nextRaw = doc.toString();
|
|
89
|
+
if (nextRaw === configRaw) {
|
|
90
|
+
return { status: 'UNCHANGED', configPath, units: updates };
|
|
91
|
+
}
|
|
92
|
+
const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
|
|
93
|
+
try {
|
|
94
|
+
await writeFile(tempPath, nextRaw, {
|
|
95
|
+
encoding: 'utf8',
|
|
96
|
+
mode: configStat.mode & 0o777,
|
|
97
|
+
flag: 'wx',
|
|
98
|
+
});
|
|
99
|
+
await rename(tempPath, configPath);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
102
|
+
throw new ReleaseError(CONFIG_INVALID, `cannot update project release metadata: ${error.message}`);
|
|
103
|
+
}
|
|
104
|
+
return { status: 'UPDATED', configPath, units: updates };
|
|
105
|
+
}
|