android-publisher-cli 0.0.0-stage → 0.2.0-dev
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/.node-version +1 -0
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +53 -0
- package/CONTRIBUTING.md +55 -0
- package/LICENSE +21 -0
- package/LICENSE.zh-CN.md +11 -0
- package/README.md +33 -2
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/build/artifact.js +151 -0
- package/build/channels/auth.js +65 -0
- package/build/channels/honor.js +128 -0
- package/build/channels/http.js +189 -0
- package/build/channels/huawei.js +138 -0
- package/build/channels/index.js +17 -0
- package/build/channels/oppo.js +169 -0
- package/build/channels/tencent.js +96 -0
- package/build/channels/types.js +53 -0
- package/build/channels/vivo.js +120 -0
- package/build/channels/xiaomi.js +99 -0
- package/build/cli.js +275 -0
- package/build/credentials.js +118 -0
- package/build/engine.js +273 -0
- package/build/model.js +160 -0
- package/build/private-file.js +193 -0
- package/build/state.js +298 -0
- package/docs/architecture.md +35 -0
- package/docs/archive/public-beta-acceptance.md +47 -0
- package/docs/channels/automatic-observation.md +37 -0
- package/docs/channels/protocol-evidence.md +35 -0
- package/docs/colleague-quickstart.md +69 -0
- package/docs/daily-cli-workflow.md +101 -0
- package/docs/execution-plan.md +14 -0
- package/docs/mac-cli-delivery.md +36 -0
- package/docs/release-process.md +51 -0
- package/package.json +34 -4
package/build/state.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { digest } from './model.js';
|
|
5
|
+
import { dataRoot, privateDir } from './private-file.js';
|
|
6
|
+
function rows(db, sql, ...params) {
|
|
7
|
+
return db.prepare(sql).all(...params);
|
|
8
|
+
}
|
|
9
|
+
function row(db, sql, ...params) {
|
|
10
|
+
return db.prepare(sql).get(...params);
|
|
11
|
+
}
|
|
12
|
+
export class Store {
|
|
13
|
+
db;
|
|
14
|
+
root;
|
|
15
|
+
constructor(root = dataRoot()) {
|
|
16
|
+
process.umask(0o077);
|
|
17
|
+
this.root = root;
|
|
18
|
+
privateDir(root);
|
|
19
|
+
const file = path.join(root, 'state.sqlite');
|
|
20
|
+
if (fs.existsSync(file) && (!fs.lstatSync(file).isFile() || fs.lstatSync(file).isSymbolicLink()))
|
|
21
|
+
throw new Error('state path is not a regular file');
|
|
22
|
+
this.db = new DatabaseSync(file, { timeout: 1000 });
|
|
23
|
+
fs.chmodSync(file, 0o600);
|
|
24
|
+
this.db.exec('PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=1000;');
|
|
25
|
+
const version = row(this.db, 'PRAGMA user_version')?.user_version ?? 0;
|
|
26
|
+
if (version !== 0 && version !== 2)
|
|
27
|
+
throw new Error(`unsupported state schema: ${version}`);
|
|
28
|
+
if (version === 0)
|
|
29
|
+
this.db.exec(`
|
|
30
|
+
BEGIN IMMEDIATE;
|
|
31
|
+
CREATE TABLE batches (
|
|
32
|
+
id TEXT PRIMARY KEY, digest TEXT NOT NULL UNIQUE, document TEXT NOT NULL,
|
|
33
|
+
created_at TEXT NOT NULL
|
|
34
|
+
);
|
|
35
|
+
CREATE TABLE targets (
|
|
36
|
+
batch_id TEXT NOT NULL REFERENCES batches(id), channel TEXT NOT NULL,
|
|
37
|
+
scope TEXT NOT NULL, phase TEXT NOT NULL DEFAULT 'prepared',
|
|
38
|
+
review TEXT NOT NULL DEFAULT 'unknown', availability TEXT NOT NULL DEFAULT 'unknown',
|
|
39
|
+
preflight TEXT NOT NULL DEFAULT '', preflight_digest TEXT NOT NULL DEFAULT '',
|
|
40
|
+
remote_ref TEXT NOT NULL DEFAULT '', query_status TEXT NOT NULL DEFAULT 'never',
|
|
41
|
+
last_success_at TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '',
|
|
42
|
+
disputed INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(batch_id, channel)
|
|
43
|
+
);
|
|
44
|
+
CREATE TABLE operations (
|
|
45
|
+
id TEXT PRIMARY KEY, batch_id TEXT NOT NULL, channel TEXT NOT NULL, scope TEXT NOT NULL,
|
|
46
|
+
step TEXT NOT NULL, attempt INTEGER NOT NULL, request_digest TEXT NOT NULL,
|
|
47
|
+
effect TEXT NOT NULL CHECK(effect IN ('intent','unknown','confirmed_applied','confirmed_rejected','confirmed_not_applied')),
|
|
48
|
+
remote_ref TEXT NOT NULL DEFAULT '', started_at TEXT NOT NULL, completed_at TEXT NOT NULL DEFAULT '',
|
|
49
|
+
UNIQUE(batch_id, channel, step, attempt),
|
|
50
|
+
FOREIGN KEY(batch_id, channel) REFERENCES targets(batch_id, channel)
|
|
51
|
+
);
|
|
52
|
+
CREATE INDEX operations_scope ON operations(scope, effect);
|
|
53
|
+
CREATE TABLE materials (
|
|
54
|
+
operation_id TEXT PRIMARY KEY REFERENCES operations(id),
|
|
55
|
+
document TEXT NOT NULL, expires_at TEXT NOT NULL
|
|
56
|
+
);
|
|
57
|
+
CREATE TABLE token_leases (
|
|
58
|
+
scope TEXT PRIMARY KEY, effect TEXT NOT NULL, token TEXT NOT NULL DEFAULT '',
|
|
59
|
+
expires_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL
|
|
60
|
+
);
|
|
61
|
+
CREATE TABLE pacing (scope TEXT PRIMARY KEY, next_at INTEGER NOT NULL);
|
|
62
|
+
CREATE TABLE observations (
|
|
63
|
+
id INTEGER PRIMARY KEY, batch_id TEXT NOT NULL, channel TEXT NOT NULL,
|
|
64
|
+
document TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
65
|
+
FOREIGN KEY(batch_id, channel) REFERENCES targets(batch_id, channel)
|
|
66
|
+
);
|
|
67
|
+
PRAGMA user_version=2;
|
|
68
|
+
COMMIT;
|
|
69
|
+
`);
|
|
70
|
+
}
|
|
71
|
+
close() { this.db.close(); }
|
|
72
|
+
tx(use) {
|
|
73
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
74
|
+
try {
|
|
75
|
+
const result = use();
|
|
76
|
+
this.db.exec('COMMIT');
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
this.db.exec('ROLLBACK');
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
saveBatch(batch) {
|
|
85
|
+
const { digest: declaredDigest, created_at: _created, ...content } = batch;
|
|
86
|
+
if (declaredDigest !== digest(content) || new Set(batch.targets.map(target => target.channel)).size !== batch.targets.length ||
|
|
87
|
+
!batch.targets.length)
|
|
88
|
+
throw new Error('batch content is not a valid immutable intent');
|
|
89
|
+
const existing = this.loadBatchOrNull(batch.batch_id);
|
|
90
|
+
if (existing) {
|
|
91
|
+
if (existing.digest !== batch.digest)
|
|
92
|
+
throw new Error('batch ID already has another immutable intent');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.tx(() => {
|
|
96
|
+
this.db.prepare('INSERT INTO batches(id,digest,document,created_at) VALUES (?,?,?,?)')
|
|
97
|
+
.run(batch.batch_id, batch.digest, JSON.stringify(batch), batch.created_at);
|
|
98
|
+
for (const target of batch.targets) {
|
|
99
|
+
const scope = digest([target.channel, target.account, target.app_id, batch.package_name]);
|
|
100
|
+
this.db.prepare('INSERT INTO targets(batch_id,channel,scope) VALUES (?,?,?)').run(batch.batch_id, target.channel, scope);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
loadBatchOrNull(id) {
|
|
105
|
+
const found = row(this.db, 'SELECT document,digest FROM batches WHERE id=?', id);
|
|
106
|
+
if (!found)
|
|
107
|
+
return null;
|
|
108
|
+
const batch = JSON.parse(found.document);
|
|
109
|
+
const { digest: declaredDigest, created_at: _created, ...content } = batch;
|
|
110
|
+
if (batch.digest !== found.digest || declaredDigest !== digest(content))
|
|
111
|
+
throw new Error('saved batch digest differs');
|
|
112
|
+
return batch;
|
|
113
|
+
}
|
|
114
|
+
loadBatch(id) { return this.loadBatchOrNull(id) ?? (() => { throw new Error('batch not found'); })(); }
|
|
115
|
+
batchIds() { return rows(this.db, 'SELECT id FROM batches ORDER BY created_at DESC').map(row => row.id); }
|
|
116
|
+
targets(id) { return rows(this.db, 'SELECT * FROM targets WHERE batch_id=? ORDER BY channel', id); }
|
|
117
|
+
target(id, channel) {
|
|
118
|
+
return row(this.db, 'SELECT * FROM targets WHERE batch_id=? AND channel=?', id, channel) ?? (() => { throw new Error('target not found'); })();
|
|
119
|
+
}
|
|
120
|
+
savePreflight(id, result) {
|
|
121
|
+
const batch = this.loadBatch(id);
|
|
122
|
+
const frozen = batch.targets.find(target => target.channel === result.channel);
|
|
123
|
+
const { digest: recorded, ...content } = result;
|
|
124
|
+
if (!frozen || recorded !== digest(content) || result.account !== frozen.account ||
|
|
125
|
+
result.app_id !== frozen.app_id || result.package_name !== batch.package_name ||
|
|
126
|
+
result.version_code !== batch.version_code)
|
|
127
|
+
throw new Error('preflight does not match the frozen target');
|
|
128
|
+
this.tx(() => {
|
|
129
|
+
const target = this.target(id, result.channel);
|
|
130
|
+
if (target.phase === 'submitted' || target.phase === 'unknown' || this.operations(id, result.channel).length)
|
|
131
|
+
throw new Error('cannot replace preflight after a write');
|
|
132
|
+
this.assertScopeAvailable(target.scope, id, result.channel);
|
|
133
|
+
this.db.prepare("UPDATE targets SET preflight=?,preflight_digest=?,phase='preflighted' WHERE batch_id=? AND channel=?")
|
|
134
|
+
.run(JSON.stringify(result), result.digest, id, result.channel);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
assertScopeAvailable(scope, batchId, channel) {
|
|
138
|
+
const conflict = row(this.db, "SELECT id FROM operations WHERE scope=? AND effect IN ('intent','unknown') AND (batch_id<>? OR channel<>?) LIMIT 1", scope, batchId, channel);
|
|
139
|
+
if (conflict)
|
|
140
|
+
throw new Error('another unresolved write owns this channel, account and application');
|
|
141
|
+
}
|
|
142
|
+
operations(id, channel) {
|
|
143
|
+
return channel
|
|
144
|
+
? rows(this.db, 'SELECT * FROM operations WHERE batch_id=? AND channel=? ORDER BY started_at,attempt', id, channel)
|
|
145
|
+
: rows(this.db, 'SELECT * FROM operations WHERE batch_id=? ORDER BY started_at,attempt', id);
|
|
146
|
+
}
|
|
147
|
+
latestOperation(id, channel, step) {
|
|
148
|
+
return row(this.db, 'SELECT * FROM operations WHERE batch_id=? AND channel=? AND step=? ORDER BY attempt DESC LIMIT 1', id, channel, step);
|
|
149
|
+
}
|
|
150
|
+
operationById(id) {
|
|
151
|
+
return row(this.db, 'SELECT * FROM operations WHERE id=?', id);
|
|
152
|
+
}
|
|
153
|
+
// The committed intent must exist before any business HTTP request is sent.
|
|
154
|
+
beginOperation(id, channel, step, requestDigest) {
|
|
155
|
+
return this.tx(() => {
|
|
156
|
+
const target = this.target(id, channel);
|
|
157
|
+
if (!target.preflight_digest)
|
|
158
|
+
throw new Error('preflight is required before a write');
|
|
159
|
+
this.assertScopeAvailable(target.scope, id, channel);
|
|
160
|
+
const prior = this.latestOperation(id, channel, step);
|
|
161
|
+
if (prior && prior.effect !== 'confirmed_not_applied')
|
|
162
|
+
throw new Error(`step ${step} is already attempted; reconcile before continuing`);
|
|
163
|
+
const attempt = (prior?.attempt ?? 0) + 1;
|
|
164
|
+
const idempotentId = digest([id, channel, step, attempt, requestDigest]);
|
|
165
|
+
const started = new Date().toISOString();
|
|
166
|
+
this.db.prepare('INSERT INTO operations(id,batch_id,channel,scope,step,attempt,request_digest,effect,started_at) VALUES (?,?,?,?,?,?,?,?,?)')
|
|
167
|
+
.run(idempotentId, id, channel, target.scope, step, attempt, requestDigest, 'intent', started);
|
|
168
|
+
return this.latestOperation(id, channel, step);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
finishOperation(op, effect, remoteRef = '', material) {
|
|
172
|
+
if (effect === 'intent')
|
|
173
|
+
throw new Error('invalid completed effect');
|
|
174
|
+
this.tx(() => {
|
|
175
|
+
const current = row(this.db, 'SELECT * FROM operations WHERE id=?', op.id);
|
|
176
|
+
if (!current || current.effect !== 'intent')
|
|
177
|
+
throw new Error('operation intent changed before completion');
|
|
178
|
+
this.db.prepare('UPDATE operations SET effect=?,remote_ref=?,completed_at=? WHERE id=?')
|
|
179
|
+
.run(effect, remoteRef, new Date().toISOString(), op.id);
|
|
180
|
+
if (material && effect === 'confirmed_applied')
|
|
181
|
+
this.db.prepare('INSERT INTO materials(operation_id,document,expires_at) VALUES (?,?,?)')
|
|
182
|
+
.run(op.id, JSON.stringify(material.value), material.expiresAt);
|
|
183
|
+
if (effect === 'unknown')
|
|
184
|
+
this.db.prepare("UPDATE targets SET phase='unknown' WHERE batch_id=? AND channel=?").run(op.batch_id, op.channel);
|
|
185
|
+
if (effect === 'confirmed_applied')
|
|
186
|
+
this.db.prepare('UPDATE targets SET remote_ref=? WHERE batch_id=? AND channel=?').run(remoteRef, op.batch_id, op.channel);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
readMaterial(op) {
|
|
190
|
+
const found = row(this.db, 'SELECT document,expires_at FROM materials WHERE operation_id=?', op.id);
|
|
191
|
+
if (!found || Date.parse(found.expires_at) <= Date.now())
|
|
192
|
+
throw new Error('saved upload authorization is missing or expired; manual recovery is required');
|
|
193
|
+
return JSON.parse(found.document);
|
|
194
|
+
}
|
|
195
|
+
beginToken(scope) {
|
|
196
|
+
return this.tx(() => {
|
|
197
|
+
const existing = row(this.db, 'SELECT effect,token,expires_at FROM token_leases WHERE scope=?', scope);
|
|
198
|
+
if (existing?.effect === 'confirmed_applied' && Date.parse(existing.expires_at) > Date.now() + 60_000)
|
|
199
|
+
return existing.token;
|
|
200
|
+
if (existing && ['intent', 'unknown'].includes(existing.effect))
|
|
201
|
+
throw new Error('account token result is unresolved; inspect the account before another token request');
|
|
202
|
+
this.db.prepare("INSERT INTO token_leases(scope,effect,token,expires_at,updated_at) VALUES (?,'intent','','',?) ON CONFLICT(scope) DO UPDATE SET effect='intent',token='',expires_at='',updated_at=excluded.updated_at")
|
|
203
|
+
.run(scope, new Date().toISOString());
|
|
204
|
+
return null;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
finishToken(scope, token, expiresAt) {
|
|
208
|
+
this.tx(() => {
|
|
209
|
+
const current = row(this.db, 'SELECT effect FROM token_leases WHERE scope=?', scope);
|
|
210
|
+
if (current?.effect !== 'intent')
|
|
211
|
+
throw new Error('token intent changed');
|
|
212
|
+
this.db.prepare("UPDATE token_leases SET effect='confirmed_applied',token=?,expires_at=?,updated_at=? WHERE scope=?")
|
|
213
|
+
.run(token, expiresAt, new Date().toISOString(), scope);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
unknownToken(scope) {
|
|
217
|
+
this.tx(() => this.db.prepare("UPDATE token_leases SET effect='unknown',token='',updated_at=? WHERE scope=? AND effect='intent'")
|
|
218
|
+
.run(new Date().toISOString(), scope));
|
|
219
|
+
}
|
|
220
|
+
reservePacing(scope, intervalMs) {
|
|
221
|
+
return this.tx(() => {
|
|
222
|
+
const prior = row(this.db, 'SELECT next_at FROM pacing WHERE scope=?', scope);
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
const start = Math.max(now, prior?.next_at ?? now);
|
|
225
|
+
this.db.prepare('INSERT INTO pacing(scope,next_at) VALUES (?,?) ON CONFLICT(scope) DO UPDATE SET next_at=excluded.next_at')
|
|
226
|
+
.run(scope, start + intervalMs);
|
|
227
|
+
return start - now;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
resolveOperation(op, effect, remoteRef = '') {
|
|
231
|
+
this.tx(() => {
|
|
232
|
+
const current = row(this.db, 'SELECT * FROM operations WHERE id=?', op.id);
|
|
233
|
+
if (!current || !['intent', 'unknown'].includes(current.effect))
|
|
234
|
+
throw new Error('operation is not unresolved');
|
|
235
|
+
this.db.prepare('UPDATE operations SET effect=?,remote_ref=?,completed_at=? WHERE id=?')
|
|
236
|
+
.run(effect, remoteRef, new Date().toISOString(), op.id);
|
|
237
|
+
this.db.prepare("UPDATE targets SET phase='preflighted',remote_ref=? WHERE batch_id=? AND channel=?")
|
|
238
|
+
.run(remoteRef, op.batch_id, op.channel);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
setSubmitted(id, channel, ref) {
|
|
242
|
+
this.tx(() => this.db.prepare("UPDATE targets SET phase='submitted',remote_ref=? WHERE batch_id=? AND channel=?")
|
|
243
|
+
.run(ref, id, channel));
|
|
244
|
+
}
|
|
245
|
+
saveObservation(id, channel, observed) {
|
|
246
|
+
this.tx(() => {
|
|
247
|
+
const target = this.target(id, channel);
|
|
248
|
+
this.db.prepare('INSERT INTO observations(batch_id,channel,document,created_at) VALUES (?,?,?,?)')
|
|
249
|
+
.run(id, channel, JSON.stringify(observed), observed.observed_at);
|
|
250
|
+
const confirmed = observed.association === 'confirmed' && target.phase === 'submitted';
|
|
251
|
+
const review = confirmed ? observed.review : target.review;
|
|
252
|
+
const availability = confirmed ? observed.availability : target.availability;
|
|
253
|
+
this.db.prepare('UPDATE targets SET review=?,availability=?,query_status=?,last_success_at=?,last_error=? WHERE batch_id=? AND channel=?')
|
|
254
|
+
.run(review, availability, 'ok', observed.observed_at, '', id, channel);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
saveQueryFailure(id, channel, reason) {
|
|
258
|
+
this.tx(() => this.db.prepare('UPDATE targets SET query_status=?,last_error=? WHERE batch_id=? AND channel=?')
|
|
259
|
+
.run('failed', reason.slice(0, 256), id, channel));
|
|
260
|
+
}
|
|
261
|
+
observations(id, channel) {
|
|
262
|
+
const found = channel
|
|
263
|
+
? rows(this.db, 'SELECT document FROM observations WHERE batch_id=? AND channel=? ORDER BY id', id, channel)
|
|
264
|
+
: rows(this.db, 'SELECT document FROM observations WHERE batch_id=? ORDER BY id', id);
|
|
265
|
+
return found.map(r => JSON.parse(r.document));
|
|
266
|
+
}
|
|
267
|
+
summary(id) {
|
|
268
|
+
const batch = this.loadBatch(id);
|
|
269
|
+
const targets = this.targets(id).map(target => {
|
|
270
|
+
const last = this.observations(id, target.channel).at(-1);
|
|
271
|
+
const context = last && (last.association === 'unconfirmed' || target.phase !== 'submitted')
|
|
272
|
+
? { association: 'unconfirmed', source: last.source, observed_at: last.observed_at, ...last.context }
|
|
273
|
+
: undefined;
|
|
274
|
+
return { channel: target.channel, execution_phase: target.phase, review_state: target.review,
|
|
275
|
+
availability_state: target.availability, query_status: target.query_status,
|
|
276
|
+
last_success_at: target.last_success_at, last_error: target.last_error,
|
|
277
|
+
recovery_required: this.operations(id, target.channel).some(op => ['intent', 'unknown'].includes(op.effect)),
|
|
278
|
+
...(context ? { channel_context: context } : {}) };
|
|
279
|
+
});
|
|
280
|
+
const accepted = targets.filter(t => t.execution_phase === 'submitted').length;
|
|
281
|
+
const approved = targets.filter(t => t.review_state === 'approved').length;
|
|
282
|
+
const live = targets.filter(t => t.availability_state === 'live').length;
|
|
283
|
+
return { schema_version: 2, batch_id: id, batch_digest: batch.digest, total_targets: targets.length,
|
|
284
|
+
accepted_targets: accepted, approved_targets: approved, live_targets: live,
|
|
285
|
+
all_accepted: accepted === targets.length, all_approved: approved === targets.length,
|
|
286
|
+
all_live: live === targets.length, targets };
|
|
287
|
+
}
|
|
288
|
+
verifyApproval(approval) {
|
|
289
|
+
const batch = this.loadBatch(approval.batch_id);
|
|
290
|
+
if (batch.digest !== approval.batch_digest)
|
|
291
|
+
throw new Error('approval refers to another immutable batch');
|
|
292
|
+
for (const channel of approval.selected_channels) {
|
|
293
|
+
const target = this.target(batch.batch_id, channel);
|
|
294
|
+
if (!target.preflight_digest || target.preflight_digest !== approval.preflight_digests[channel])
|
|
295
|
+
throw new Error(`${channel}: preflight changed since review`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# 架构说明
|
|
2
|
+
|
|
3
|
+
本页供修改执行和状态的维护者使用。CLI 使用 Node.js 内置 SQLite 保存批次、操作和观测。操作者的命令顺序见[日常发布](daily-cli-workflow.md)。
|
|
4
|
+
|
|
5
|
+
| 代码 | 职责 |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `src/cli.ts` | 解析命令,调用批次操作,输出 JSON |
|
|
8
|
+
| `src/model.ts`、`src/private-file.ts` | 校验输入、计算摘要、读写私有文件 |
|
|
9
|
+
| `src/artifact.ts` | 用 Android 工具验签 APK,冻结并复核产物 |
|
|
10
|
+
| `src/engine.ts` | 准备批次、预检、审阅、执行和查询 |
|
|
11
|
+
| `src/state.ts` | 保存 SQLite 意图、回执、观测、令牌和授权材料 |
|
|
12
|
+
| `src/channels/` | 六个渠道的独立 HTTP 协议实现 |
|
|
13
|
+
| `src/credentials.ts` | 终端隐藏输入和可选的私有 JSON 文件输入 |
|
|
14
|
+
|
|
15
|
+
## 发布顺序
|
|
16
|
+
|
|
17
|
+
`setup` 用可信 APK 确定包名和签名,并保存公开账号配置。`prepare` 检查每个 APK 的包名、签名和版本。检查通过后,CLI 把 APK 和小米图标复制到私有目录,再保存不可变批次。这两步不访问渠道。
|
|
18
|
+
|
|
19
|
+
`batch preflight` 只读查询远端应用。荣耀和 OPPO 的令牌申请属于账号生命周期操作,状态单独保存。`batch review` 把批次摘要、选定渠道、APK 摘要和预检摘要写入批准文件。`batch submit` 在第一个业务请求前复核全部选定产物和批准内容。
|
|
20
|
+
|
|
21
|
+
每个业务步骤先在 `operations` 表提交 `intent`,再发送一次请求。取得可验证回执后,立即把结果改为 `confirmed_applied`。上传授权的签名 URL 只保存在本机私有 SQLite 材料表,不进入报告。若进程中断或响应无法验证,日志保留 `intent` 或 `unknown`。
|
|
22
|
+
|
|
23
|
+
`resume` 跳过已确认的步骤。遇到未决步骤时,它不会重发。`reconcile` 只读查询现有证据。当前六个渠道的响应丢失路径尚无足够依据自动解除阻塞;需要维护者核查。新批次也不能绕过同一渠道、账号和应用的未决写入。
|
|
24
|
+
|
|
25
|
+
## 状态与本地数据
|
|
26
|
+
|
|
27
|
+
执行、审核、上架和查询健康分别保存。渠道受理提交只确认执行。观测与本次版本无法关联时,信息进入 `channel_context`,不会增加已核实的审核或上架数量。
|
|
28
|
+
|
|
29
|
+
新版状态位于 `~/Library/Application Support/AndroidPublisherCLI/v2/`。SQLite 使用 WAL 和完全同步设置。文件与目录限制为当前用户访问。0.1.x Go 版状态没有自动迁移;旧目录仍由用户自行保管。
|
|
30
|
+
|
|
31
|
+
终端输入的渠道密钥只留在当前进程内。可选的 `--credentials-file` 由用户在本机保存,CLI 要求文件属于当前用户且权限为 `0600`。令牌和短期上传授权进入私有状态库,以便同一账号按已记录结果继续使用。
|
|
32
|
+
|
|
33
|
+
## 验收边界
|
|
34
|
+
|
|
35
|
+
`test/recovery.test.mjs` 在 HTTP 请求返回后真实终止子进程,再检查重启后的意图和重复写入阻塞。`test/android.test.mjs` 构建并验签临时 APK。`test/channels.test.mjs` 使用合成响应覆盖六个渠道。测试不能证明真实账号权限或商店未公开行为。
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# 归档:公开测试版初始验收
|
|
2
|
+
|
|
3
|
+
> 本页面向需要回溯早期交付的维护者,保留 2026-10-23 初始测试版的验收与修订记录,不作为当前操作规范。最新检查以 [CI 历史](https://github.com/wangyiliangnet/android-publisher-cli/actions/workflows/check.yml)为准。开发要求见[贡献指南](../../CONTRIBUTING.md),后续计划见[维护路线图](../execution-plan.md)。
|
|
4
|
+
|
|
5
|
+
本记录针对公开 CLI,验收使用 Apple Silicon macOS 和合成输入。验收结果不代表已核实渠道账号、应用或生产提交,也不代表完成 Apple 公证。
|
|
6
|
+
|
|
7
|
+
文中的 Go、钥匙串和原生安装包均为 0.1.x 历史行为。新版源码和发布流程已改为 TypeScript 与 npm,本页不作为新版验收结果。
|
|
8
|
+
|
|
9
|
+
## 本地验收
|
|
10
|
+
|
|
11
|
+
- 使用固定版本的 Go 和 Node.js 完成 `make check`:格式、vet、Go 竞态、真实 Chromium、临时原生钥匙串、中断子进程恢复和构建均通过。
|
|
12
|
+
- 强制 Android 集成测试通过,使用独立 Android SDK、真实 Build Tools、JDK,以及生成的测试 APK 和签名密钥。
|
|
13
|
+
- 双版本 CI 预览打包通过安装、升级、回滚、重复安装、同版本完整内容冲突、校验和损坏、额外文件、符号链接、离线文档和卸载检查。
|
|
14
|
+
- 已检查运行状态、钥匙串服务、签名标识和安装路径各自独立的命名空间。测试和预览构建不使用生产账号。
|
|
15
|
+
- 公开发布前必须通过工作流检查、发布版本检查、脚本检查、隐私回归测试、密钥扫描器精确测试例外检查及公开历史扫描。
|
|
16
|
+
|
|
17
|
+
## 独立审查
|
|
18
|
+
|
|
19
|
+
只读审查覆盖公开数据与许可边界、CI 和仓库规则,以及 Mac 交付。主要发现如下:
|
|
20
|
+
|
|
21
|
+
1. 快速入门中的 PATH 与默认安装路径不同,示例现已改为 `~/.local/android-publisher-cli/bin`。
|
|
22
|
+
2. 公开信息扫描未覆盖提交消息和附注标签元数据。现在除当前及历史文件外,还检查可达 Git 元数据。回归测试覆盖隐私和凭据模式。
|
|
23
|
+
3. 标签已创建、草稿创建却中断时,可能使该版本无法继续。恢复流程现会检查准确的标签、草稿元数据和已有资产字节。检查一致后,只上传缺失资产。
|
|
24
|
+
4. Git 替换引用可能隐藏原始历史内容。检查现在拒绝替换引用,并在每个 Git 和 Gitleaks 子进程中强制读取原始对象。
|
|
25
|
+
5. 一次真实依赖更新暴露出隐私检查对官方机器人末尾签名行限制过严。现在只为准确的官方机器人身份和签名行提供隐私例外。交给密钥扫描器的原始输入不变。源码、标签与引用文本、其他身份和邻近私有文本仍受检查。
|
|
26
|
+
|
|
27
|
+
五项问题均已修复,并由原独立审查者复核。分配范围内没有遗留 P0–P2 问题。18 项隐私回归测试、13 项草稿恢复故障测试、工作流检查、完整源码与历史及元数据扫描、最终安装包冒烟测试均通过。后续云端验收证据通过正常 PR 记录。
|
|
28
|
+
|
|
29
|
+
## 云端与发布验收
|
|
30
|
+
|
|
31
|
+
云端验收使用标准 Apple Silicon 运行器,包含完整测试和双版本安装恢复检查。常规检查不上传发布产物。独立建立的公开仓库将云端结果记录在 [CI 历史](https://github.com/wangyiliangnet/android-publisher-cli/actions/workflows/check.yml)中。
|
|
32
|
+
|
|
33
|
+
独立只读远端审查确认,两个已纳入版本管理的规则集均启用。规则集没有可绕过规则的角色,并实际约束 `main` 和 `v*` 标签。仓库只允许 squash 合并。Actions 要求固定完整提交并限用 GitHub 官方 Actions,默认只读,不能批准 PR。私密漏洞报告、密钥扫描和推送保护均已开启。
|
|
34
|
+
|
|
35
|
+
验收 PR 用于验证必需检查对合并的约束,实际结果以 GitHub 上的检查与合并事件为准。每次预览发布运行都会在 [Actions](https://github.com/wangyiliangnet/android-publisher-cli/actions/workflows/release.yml) 中分别记录当前 main 验证、完整检查、安装包验收和草稿创建任务。首个预览仍是 ad-hoc 签名、未公证的草稿。固定身份分发属于单独能力。
|
|
36
|
+
|
|
37
|
+
## 下载产物复核
|
|
38
|
+
|
|
39
|
+
首个草稿的实际云端产物已在 Mac 上通过校验和、签名、架构、干净源码提交、清单、许可证及安装恢复检查。最终独立审查发现,安装包更新记录有一处 P3 离线链接问题。打包工具现会改写该链接。安装包验收也会检查根目录全部 Markdown 文档,扩展后的检查能够拒绝原压缩包。修复使用新的 `0.1.0-beta.2` 候选,不替换先前标签或安装包字节。
|
|
40
|
+
|
|
41
|
+
## 脱敏后的公开仓库基线
|
|
42
|
+
|
|
43
|
+
公开仓库从一份已审查、使用 noreply 元数据的源码快照开始。创建 PR 前,贡献者必须先配置网页提交身份隐私。云端检查和预览草稿会针对该公开基线重新执行。较早的评估产物私下保留,不将先前 Git 对象导入此仓库。
|
|
44
|
+
|
|
45
|
+
独立审查确认,新公开基线只有一个 noreply 根提交,不含先前 Git 对象。该基线与已审源码的差异仅为文档。匿名查询无法通过公开仓库或私有归档读取先前的测试合并对象。创建新仓库的验收 PR 前,已检查贡献者账号隐私设置。
|
|
46
|
+
|
|
47
|
+
云端一次 Chromium 冷启动和采集超过了验收测试数据原先的 10 秒请求时限。现在使用协议已有的 30 秒上限。运行行为和协议限制不变,专门的截止时间与取消测试仍使用各自较短的时限。超时后不会自动重试或跳过检查。
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# 自动查询说明
|
|
2
|
+
|
|
3
|
+
提交后运行以下命令查询当前批次:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
android-release batch status --batch demo-1-0-0 --refresh
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
CLI 会在终端提示输入所需凭据。去掉 `--refresh` 时,只读取本地缓存。查看结果时,先看进程退出码,再看 `targets` 中每个渠道的字段。
|
|
10
|
+
|
|
11
|
+
| 字段 | 含义 |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `execution_phase` | `submitted` 只表示渠道已受理本次提交 |
|
|
14
|
+
| `review_state` | 版本关联证据支持的审核状态 |
|
|
15
|
+
| `availability_state` | 版本关联证据支持的公开上架状态 |
|
|
16
|
+
| `query_status`、`last_success_at` | 上次查询的健康状态和成功时间 |
|
|
17
|
+
| `recovery_required` | 存在未决业务写入,不能直接重发 |
|
|
18
|
+
|
|
19
|
+
审核通过不等于公开上架。查询失败时,CLI 保留已有成功时间,并把 `query_status` 记为 `failed`。缓存不能看作刚取得的结果。
|
|
20
|
+
|
|
21
|
+
## 各渠道如何关联结果
|
|
22
|
+
|
|
23
|
+
华为在本地已有受理回执,并且 API 返回目标版本时,才读取该版本的审核和在架字段。荣耀需要本次提交回执中的 `releaseId`,并核对审核查询返回的相同标识。
|
|
24
|
+
|
|
25
|
+
OPPO 要求目标版本、已上传 APK 的 MD5 和版本标识一致。vivo 要求详情接口返回目标版本。两个渠道的审核与上架字段分别记录。
|
|
26
|
+
|
|
27
|
+
小米包查询只给出账号下的版本。目标版本出现在查询结果里,仍不能证明本次审核或公开上架。应用宝审核接口也没有本次版本标识。这两类结果写入 `channel_context`,并标为 `unconfirmed`。它们不会增加 `approved_targets` 或 `live_targets`。
|
|
28
|
+
|
|
29
|
+
## 未决写入
|
|
30
|
+
|
|
31
|
+
网络中断或进程结束后,先读取本地状态,再运行:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
android-release batch reconcile --batch demo-1-0-0
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`reconcile` 只做读取。当前适配器没有足够的响应丢失核对依据,因此未决操作会继续阻塞。`resume` 不会重发未决步骤。请在渠道后台核实具体操作,再交给维护者处理;不要删除 SQLite 记录来绕过阻塞。
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# 渠道协议与验证依据
|
|
2
|
+
|
|
3
|
+
本页供修改渠道请求的维护者使用。新版 TypeScript 适配器只支持已有应用的版本更新,发布规则为 `after_approval`。`android-release protocols` 可在本机查看实现范围。
|
|
4
|
+
|
|
5
|
+
## 当前能力
|
|
6
|
+
|
|
7
|
+
| 渠道 | 写入步骤 | 仍需注意 |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| 华为 | 申请上传授权、上传、绑定、更新中文说明、提审 | 上传地址和签名头必须匹配授权;绑定后至少等待 2 分钟再提审 |
|
|
10
|
+
| 荣耀 | 申请上传授权、上传、绑定、更新中文说明、提审 | 审核查询需要本次提交的 `releaseId`;更新语言时使用 `setAll: 0` |
|
|
11
|
+
| 小米 | 签名后推送 APK 与图标 | 包查询没有版本绑定的审核结果;测试账号按结构化 JSON 输入 |
|
|
12
|
+
| OPPO | 申请上传授权、上传、提审 | 授权本地使用期限为 30 秒;预检需要完整的已有应用元数据 |
|
|
13
|
+
| vivo | 上传、提审 | 单 APK 更新;签名方法及账号权限仍待真实账号核对;请求间隔至少 1 分钟 |
|
|
14
|
+
| 应用宝 | 申请上传授权、上传、提审 | 使用主开发者账号;审核接口未给出本次版本标识 |
|
|
15
|
+
|
|
16
|
+
六个适配器通过了合成响应测试。测试覆盖只读预检、写入步骤、回执保存和状态分离。**`protocol_mock_verified` 只表示合成测试通过。** `account_verified` 和 `live_verified` 尚无新版真实账号证据。不要从测试结果推断渠道授权、审核结果或公开上架。
|
|
17
|
+
|
|
18
|
+
响应丢失时,渠道查询通常缺少足够的操作标识或产物摘要。新版 `reconcile` 保留未决写入,不自动重试。补充恢复逻辑前,必须记录可核对的官方字段和对应测试。
|
|
19
|
+
|
|
20
|
+
## 官方资料
|
|
21
|
+
|
|
22
|
+
下表沿用 2026-10-23 整理的官方入口。页面可能调整、要求登录或动态加载。修改协议前,重新核对实际接口页面和账号响应。
|
|
23
|
+
|
|
24
|
+
| 渠道 | 官方入口 | 核对重点 |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| 华为 | [AppGallery Connect API 简介](https://developer.huawei.com/consumer/en/doc/AppGallery-connect-Guides/agcapi-overview-0000001158245083) | 服务账号、上传授权、应用信息和提审字段 |
|
|
27
|
+
| 荣耀 | [API 包上传指南](https://developer.honor.com/cn/doc/guides/101359) | OAuth、应用标识、上传对象和发布结果 |
|
|
28
|
+
| 小米 | [自动发布 API 指南](https://dev.mi.com/xiaomihyperos/documentation/detail?pId=1134) | SIG、包查询、推送和测试账号结构 |
|
|
29
|
+
| OPPO | [开发者服务中心](https://open.oppomobile.com/) | 令牌、上传地址、已有应用元数据和提审结果 |
|
|
30
|
+
| vivo | [API 接入指南](https://developer.vivo.com/doc/detail?id=73) | 签名方法、账号平台和发布字段 |
|
|
31
|
+
| 应用宝 | [开发者 API 指南](https://wiki.open.qq.com/index.html#/guide/develop?id=4015262492) | 主开发者账号、文件序列号和审核查询 |
|
|
32
|
+
|
|
33
|
+
在 PR 中写明文档页面、字段、访问限制和仍待账号验证的行为。新增成功、错误及响应丢失的合成用例。不能把缺失字段、未知状态码或旧版本信息解释为本次发布成功。
|
|
34
|
+
|
|
35
|
+
协议代码独立实现。第三方来源和许可证见[第三方声明](../../THIRD_PARTY_NOTICES.md)。
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# 首次使用
|
|
2
|
+
|
|
3
|
+
本页供第一次安装和配置 CLI 的发版同事使用。你需要 Apple Silicon Mac、Android SDK Build Tools、JDK,以及 [`.node-version`](../.node-version) 指定的 Node.js。使用 npm 安装包时不需要 Go。
|
|
4
|
+
|
|
5
|
+
## 安装 CLI
|
|
6
|
+
|
|
7
|
+
安装 npm 上的正式版本,并核对命令。首次公开发布前,可从维护者取得已验收的 npm tarball 和 SHA-256 文件,先核对摘要再安装。下面的版本号仅作 tarball 示例:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install --global android-publisher-cli
|
|
11
|
+
android-release version --json
|
|
12
|
+
android-release doctor
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
离线安装时使用维护者交付的文件:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
shasum -a 256 -c android-publisher-cli-0.2.0.tgz.sha256
|
|
19
|
+
npm install --global ./android-publisher-cli-0.2.0.tgz
|
|
20
|
+
android-release version --json
|
|
21
|
+
android-release doctor
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
校验输出应为 `OK`。`version` 应显示你安装的版本。`doctor` 的 `ready` 为 `true` 时,表示已找到 APK 检查工具。若工具未找到,先检查 SDK 和 JDK 路径,再使用 `ANDROID_SDK_ROOT` 与 `JAVA_HOME` 指向本机安装目录。
|
|
25
|
+
|
|
26
|
+
从源码试用时,在仓库根目录执行 `npm ci --ignore-scripts`、`npm run build` 和 `npm link`。正式交付与核对步骤见[构建与分发](mac-cli-delivery.md)。
|
|
27
|
+
|
|
28
|
+
## 配置应用
|
|
29
|
+
|
|
30
|
+
准备一份你信任的已签名、不可调试 APK。下面的命令读取包名和签名,并打开项目向导;它不会上传 APK:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
android-release setup --project demo --apk "/absolute/path/trusted.apk"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
先核对显示的包名与签名 SHA-256。输入 `yes` 后选择渠道,填写公开账号和应用标识。最后再次输入 `yes` 保存项目。输入 `cancel` 可退出向导。项目保存在 `~/Library/Application Support/AndroidPublisherCLI/v2/projects/`。
|
|
37
|
+
|
|
38
|
+
| 渠道 | 向导需要的公开信息 | 执行命令时输入的凭据 |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| 华为 | 本机账号名、渠道应用 ID,可选中文语言 ID | 服务账号 JSON |
|
|
41
|
+
| 荣耀 | 本机账号名、渠道应用 ID、`client_id`,可选中文语言 ID | `client_secret` |
|
|
42
|
+
| 小米 | 本机账号名、`userName`、图标路径、隐私政策 URL;应用 ID 使用包名 | API 密钥、公钥证书和按需提供的测试账号 JSON |
|
|
43
|
+
| OPPO | `client_id` 兼作本机账号名;应用 ID 使用包名 | `client_secret` |
|
|
44
|
+
| vivo | 本机账号名、`access_key`;应用 ID 使用包名 | `access_secret` |
|
|
45
|
+
| 应用宝 | 本机账号名、应用 AppID、账号 uid | API 密钥 |
|
|
46
|
+
|
|
47
|
+
账号和应用标识要与渠道后台一致。配置保存的是公开标识,渠道权限还需通过预检确认。需要重设项目时,加 `--replace` 并重新核对可信 APK。已准备的旧批次不会随项目配置改变。
|
|
48
|
+
|
|
49
|
+
## 输入凭据
|
|
50
|
+
|
|
51
|
+
`batch preflight`、`batch submit` 和刷新查询时,CLI 默认在终端逐项提示凭据。输入不会显示在屏幕上,也不会写入项目配置。华为服务账号 JSON 和小米公钥证书可先放在当前用户私有文件里。在提示处输入 `@` 加绝对路径,例如 `@/absolute/path/service-account.json`。
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
chmod 600 "/absolute/path/service-account.json"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
自动化可使用 `--credentials-file /absolute/path/credentials.json`。文件须属于当前用户,权限为 `0600`。顶层按渠道名分组;每组字段名见 CLI 提示。请把文件放在仓库外,并自行管理它的备份和轮换。
|
|
58
|
+
|
|
59
|
+
CLI 不读取钥匙串。短期访问令牌和上传授权会保存在本机私有状态库。真实账号、私钥和凭据文件不能提交到 Git,也不能附在 issue 或报告里。
|
|
60
|
+
|
|
61
|
+
## 升级与卸载
|
|
62
|
+
|
|
63
|
+
停止正在运行的发布命令,再安装指定版本的 npm 包或 tarball。运行 `android-release version --json` 核对版本。回退时,重新安装你保留的旧版包;先确认旧程序能读取当前 `v2` 状态结构。预发布版本可用 `npm install --global android-publisher-cli@next` 安装。
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npm uninstall --global android-publisher-cli
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
卸载 npm 包不会删除发布状态或凭据文件。0.1.x Go 版状态不会自动迁移到新版。发布步骤见[日常发布](daily-cli-workflow.md)。
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# 日常发布、查询与恢复
|
|
2
|
+
|
|
3
|
+
本页以项目 `demo` 一次更新六个渠道为例。先完成[首次配置](colleague-quickstart.md)。准备各渠道 APK 和更新说明后,再建立本次批次。所有示例路径都要换成本机绝对路径。
|
|
4
|
+
|
|
5
|
+
## 准备批次
|
|
6
|
+
|
|
7
|
+
把本次更新说明写入私有文本文件。再创建 `batch.json`,逐个指定 APK;CLI 不搜索目录或猜测“最新”文件。
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"schema_version": 2,
|
|
12
|
+
"batch_id": "demo-1-0-0",
|
|
13
|
+
"project_id": "demo",
|
|
14
|
+
"release_notes_file": "/absolute/path/release-notes.txt",
|
|
15
|
+
"apks": {
|
|
16
|
+
"huawei": "/absolute/path/huawei.apk",
|
|
17
|
+
"honor": "/absolute/path/honor.apk",
|
|
18
|
+
"xiaomi": "/absolute/path/xiaomi.apk",
|
|
19
|
+
"oppo": "/absolute/path/oppo.apk",
|
|
20
|
+
"vivo": "/absolute/path/vivo.apk",
|
|
21
|
+
"tencent": "/absolute/path/tencent.apk"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
android-release prepare --file "/absolute/path/batch.json"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
命令会验签每份 APK,核对项目包名、签名及一致的版本。随后把产物复制到本机私有目录,并保存不可变批次。输出的 `batch_id` 应是 `demo-1-0-0`,`total_targets` 应为 `6`。失败时修正输入,再重新运行。换 APK 或说明时,使用新的批次名。
|
|
31
|
+
|
|
32
|
+
`prepare` 不访问渠道。下一步才会读取真实账号数据:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
android-release batch preflight --batch demo-1-0-0
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
CLI 会在终端提示六个渠道的凭据。预检读取账号和应用状态,并保存与批次绑定的结果。它不上传 APK 或提审。查看 `errors`,逐项处理失败的渠道。某个渠道成功不能代替整个批次通过。
|
|
39
|
+
|
|
40
|
+
## 审阅与提交
|
|
41
|
+
|
|
42
|
+
预检全部通过后,先建好仅自己可访问的输出目录,再生成批准文件:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
android-release batch review --batch demo-1-0-0 \
|
|
46
|
+
--file "/absolute/path/approval.json"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
核对输出中的账号、应用标识、包名、版本、渠道、APK 摘要、签名、说明和 `release_policy`。文件和输出中的 `approval.approval_plan` 是本次计划摘要。批准文件含发布内容,应私下保存。
|
|
50
|
+
|
|
51
|
+
**下面的命令会上传 APK 并向六个渠道提审。** 确认你已获得覆盖应用、产物和渠道范围的授权。把摘要原样填入命令:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
android-release batch submit --batch demo-1-0-0 \
|
|
55
|
+
--approval "/absolute/path/approval.json" \
|
|
56
|
+
--approve-plan APPROVAL_PLAN_FROM_REVIEW
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
提交前,CLI 再次核对批准文件、预检和所有选定 APK。每个业务请求都先记录意图,再保存可验证的回执。`accepted_targets` 统计已受理的渠道,不表示审核通过或公开上架。部分失败时保留全部输出和本地状态。
|
|
60
|
+
|
|
61
|
+
只获准提交部分渠道时,在 `preflight` 和 `review` 使用相同的 `--channels oppo,honor`。提交命令直接使用批准文件绑定的选择,不再传 `--channels`。
|
|
62
|
+
|
|
63
|
+
## 查询与报告
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
android-release batch status --batch demo-1-0-0 --refresh
|
|
67
|
+
android-release batch report --batch demo-1-0-0 \
|
|
68
|
+
--file "/absolute/path/report.md"
|
|
69
|
+
android-release history
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
刷新状态会提示输入凭据,并分别记录执行、审核、上架和查询健康。不加 `--refresh` 时,`status` 只读缓存。理解小米和应用宝的未关联信息,请读[自动查询说明](channels/automatic-observation.md)。
|
|
73
|
+
|
|
74
|
+
报告文件的父目录也须已存在。报告只含本地摘要和已关联状态,不含渠道密钥、令牌或签名 URL。报告仍可能暴露包名、版本和发布进度;分享前请检查。
|
|
75
|
+
|
|
76
|
+
## 中断与恢复
|
|
77
|
+
|
|
78
|
+
网络错误或终端退出后,先看缓存,再运行只读核对:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
android-release batch status --batch demo-1-0-0
|
|
82
|
+
android-release batch reconcile --batch demo-1-0-0
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
响应丢失时,请求可能已经生效。当前协议证据不足以自动确认这些写入;`reconcile` 会保留阻塞。不要删除状态库、修改回执或创建新批次来绕过未决写入。核查远端结果后,交给维护者处理。
|
|
86
|
+
|
|
87
|
+
若没有未决写入,且只是后续步骤未完成,可继续使用原批准文件:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
android-release batch resume --batch demo-1-0-0 \
|
|
91
|
+
--approval "/absolute/path/approval.json" \
|
|
92
|
+
--approve-plan APPROVAL_PLAN_FROM_REVIEW
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`resume` 跳过已确认步骤,并复核批准内容。退出码 `3` 表示部分目标失败或结果未决。退出码 `2` 表示输入或配置错误。任何提交错误都要先查看 `recovery_required`。
|
|
96
|
+
|
|
97
|
+
## 本地数据
|
|
98
|
+
|
|
99
|
+
新版项目、冻结产物、SQLite 日志、令牌和上传授权位于 `~/Library/Application Support/AndroidPublisherCLI/v2/`。0.1.x Go 版数据仍留在原目录,新版不会读取或迁移它。
|
|
100
|
+
|
|
101
|
+
当前版本没有状态备份或恢复命令。需要备份时,先停止所有 CLI 进程,再私下复制整个 `v2` 目录及 SQLite 附属文件。恢复后先核对远端结果,再决定是否允许继续发布。备份含敏感状态,不能进入仓库或构建产物。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# 维护路线图
|
|
2
|
+
|
|
3
|
+
本页记录新版仍需验证和实现的工作。当前命令见 [README](../README.md)。修改规则见[贡献指南](../CONTRIBUTING.md)。
|
|
4
|
+
|
|
5
|
+
| 工作 | 验收条件 |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| 逐一验证六个渠道的测试账号 | 保存不含凭据的字段和结果摘要,区分账号权限与公开上架 |
|
|
8
|
+
| 补充响应丢失核对依据 | 只在官方协议能绑定本次操作时解除未决写入 |
|
|
9
|
+
| 增加新版状态备份与恢复 | 恢复后先核对远端,阻止旧备份触发重复写入 |
|
|
10
|
+
| 验收 npm 包交付 | 从实际 tarball 安装,核对文件清单和版本 |
|
|
11
|
+
|
|
12
|
+
首次上架、定时发布、旧版 Go 状态迁移和 npm 注册表发布不在当前实现范围内。新增范围时,先写明操作者的步骤和验收条件。
|
|
13
|
+
|
|
14
|
+
0.1.x 公开测试版的历史检查见[归档记录](archive/public-beta-acceptance.md)。归档描述旧版 Go 实现,不能作为新版账号验证结果。
|