@taprootio/site-authoring 0.12.0 → 0.12.3

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/README.md CHANGED
@@ -400,7 +400,11 @@ site.
400
400
  ## Output contract
401
401
 
402
402
  Human progress goes to stderr and `--quiet` silences it (except for `login`,
403
- whose approval code and URL exist only as progress). Stdout carries exactly
403
+ whose approval code and URL exist only as progress). While a deployment is
404
+ awaited, a terminal shows one in-place line (spinner, phase, elapsed time) that
405
+ starts a new line only when the phase changes; a pipe or CI log never receives
406
+ control characters, only one line per phase change plus a heartbeat at most
407
+ every 60 seconds. Stdout carries exactly
404
408
  one JSON object per run, schema version `1`: `ok`, the CLI name and version,
405
409
  the verb, and the verb's own result on success; a stable `error.code` with an
406
410
  optional `field`, `status`, and classified `refusal` on failure. Exit codes are
@@ -501,7 +505,7 @@ fingerprint supply no gating evidence. Typed preview failure diagnostics contain
501
505
  only error class and known page/node/attribute labels, never renderer messages.
502
506
 
503
507
  Staging deploys check the current redirect map through the real staging edge.
504
- Run `taproot-site redirects check` to repeat after eventual edge propagation.
508
+ Run `taproot-site redirects check` to repeat once the deployment has completed; a redirect is a file inside the release, so it is live exactly when its release is served.
505
509
  The Node CLI consumes a separate handoff, retains its short staging cookie in
506
510
  memory, and requests each map path without following redirects. Results report
507
511
  path, HTTP status and Location; `verified` requires all entries to match and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.12.0",
3
+ "version": "0.12.3",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
package/src/api.js CHANGED
@@ -1207,7 +1207,7 @@ export async function poll({
1207
1207
  // poll runs every few seconds for up to fifteen minutes, and a line per
1208
1208
  // tick scrolls the one thing the operator still needs — the code — off the
1209
1209
  // top of the terminal.
1210
- if (result.progress !== undefined) onProgress(result.progress);
1210
+ if (result.progress !== undefined) onProgress(result.progress, result.event);
1211
1211
  if (now() >= deadline) break;
1212
1212
  await client.sleep(Math.min(intervalMilliseconds, Math.max(1, deadline - now())), client.signal);
1213
1213
  }
@@ -1220,8 +1220,20 @@ export async function poll({
1220
1220
  }
1221
1221
 
1222
1222
  export async function waitForDeployment(client, { siteId, deploymentId, environment, onProgress, now }) {
1223
+ try {
1224
+ return await awaitDeployment(client, { siteId, deploymentId, environment, onProgress, now });
1225
+ } finally {
1226
+ // The wait is over whether the deployment finished or failed; the verb's later
1227
+ // output (staging checks, the failure text) must not sit beside a spinner.
1228
+ onProgress?.endWait?.();
1229
+ }
1230
+ }
1231
+
1232
+ async function awaitDeployment(client, { siteId, deploymentId, environment, onProgress, now }) {
1223
1233
  let misses = 0;
1224
1234
  let lastEvidence;
1235
+ let truncatedReported = false;
1236
+ const reportedPhases = new Set();
1225
1237
  return await poll({
1226
1238
  client,
1227
1239
  now,
@@ -1248,6 +1260,11 @@ export async function waitForDeployment(client, { siteId, deploymentId, environm
1248
1260
  lastEvidence = evidence;
1249
1261
  if (deployment.phaseTimings.known) {
1250
1262
  for (const phase of deployment.phaseTimings.phases) {
1263
+ // Each phase is reported when it is entered and again when it ends,
1264
+ // never re-listed on every later change.
1265
+ const key = `${phase.status}|${phase.enteredAt}|${phase.durationMilliseconds ?? ""}`;
1266
+ if (reportedPhases.has(key)) continue;
1267
+ reportedPhases.add(key);
1251
1268
  onProgress(
1252
1269
  `Deployment phase ${phase.status} entered ${phase.enteredAt}`
1253
1270
  + (phase.durationMilliseconds === undefined
@@ -1255,7 +1272,8 @@ export async function waitForDeployment(client, { siteId, deploymentId, environm
1255
1272
  : `; ${phase.durationMilliseconds}ms until ${phase.endedAt}.`),
1256
1273
  );
1257
1274
  }
1258
- if (deployment.phaseTimings.truncated) {
1275
+ if (deployment.phaseTimings.truncated && !truncatedReported) {
1276
+ truncatedReported = true;
1259
1277
  onProgress("Earlier deployment phase evidence was truncated by the server.");
1260
1278
  }
1261
1279
  } else onProgress("Deployment phase timings are unknown for this legacy deployment.");
@@ -1270,7 +1288,11 @@ export async function waitForDeployment(client, { siteId, deploymentId, environm
1270
1288
  { status: deployment.status },
1271
1289
  );
1272
1290
  }
1273
- return { done: false, progress: `Waiting for the deployment (${deployment.status}).` };
1291
+ // The phase a reader sees is the latest one the server recorded, or the
1292
+ // deployment's own status when it recorded none.
1293
+ const phases = deployment.phaseTimings.known ? deployment.phaseTimings.phases : [];
1294
+ const phase = phases.length > 0 ? phases[phases.length - 1].status : deployment.status;
1295
+ return { done: false, progress: `Waiting for the deployment (${phase}).`, event: { phase } };
1274
1296
  },
1275
1297
  timeoutCode: "deploy.timeout",
1276
1298
  });
package/src/cli.js CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  SiteAuthoringError,
48
48
  } from "./errors.js";
49
49
  import { failureResult, humanFailure, serializeResult, writeGithubActionsOutput } from "./output.js";
50
+ import { createProgressReporter } from "./progress.js";
50
51
  import {
51
52
  formatReferenceResult,
52
53
  getAppearanceReference,
@@ -251,7 +252,8 @@ const VERBS = Object.freeze([
251
252
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
252
253
  tokens: ["redirects", "check"],
253
254
  summary: "Check the current redirect map through the authenticated staging edge.",
254
- note: "Reports path, HTTP status and Location without following redirects. Re-run after edge propagation. "
255
+ note: "Reports path, HTTP status and Location without following redirects. A redirect is live only with the release "
256
+ + "that carries it, so this reads the staged release. "
255
257
  + "The result includes a fresh single-use staging handoff; keep it private.",
256
258
  },
257
259
  {
@@ -275,7 +277,8 @@ const VERBS = Object.freeze([
275
277
  summary: "Validate redirects.json and replace the whole redirect map.",
276
278
  note: "Run 'redirects pull' first. The whole file is validated locally by entry index — path normalization, "
277
279
  + "targets, statuses, duplicates, chains, and loops — before anything is sent, and the site refuses a "
278
- + "source a live page occupies. Entries take effect on the next deploy. "
280
+ + "source a live page occupies or a file the site generates. An entry is a file inside the next release, so it "
281
+ + "goes live with that deploy: staging first, production when promoted. "
279
282
  + "See 'taproot-site help redirects'.",
280
283
  },
281
284
  {
@@ -1132,6 +1135,7 @@ export async function runCli({
1132
1135
  signal,
1133
1136
  } = {}) {
1134
1137
  let parsed;
1138
+ let progress;
1135
1139
  let completedPreviewRecovery;
1136
1140
  const isReferenceInvocation = Array.isArray(arguments_)
1137
1141
  && (
@@ -1176,6 +1180,11 @@ export async function runCli({
1176
1180
  if (typeof handler !== "function") {
1177
1181
  throw usageError("cli.unsupported_verb", `No handler is registered for '${parsed.verb}'.`);
1178
1182
  }
1183
+ progress = createProgressReporter({
1184
+ stream: stderr,
1185
+ interactive: stderr.isTTY === true && environment.TERM !== "dumb",
1186
+ quiet: parsed.quiet,
1187
+ });
1179
1188
  const result = await handler(Object.freeze({
1180
1189
  verb: parsed.verb,
1181
1190
  cwd,
@@ -1200,13 +1209,17 @@ export async function runCli({
1200
1209
  : parsed.positionals.values,
1201
1210
  }
1202
1211
  : {}),
1203
- onProgress: parsed.quiet ? () => {} : (message) => stderr.write(`${message}\n`),
1212
+ // `endWait` lets a wait say it is over while the verb goes on (staging checks).
1213
+ onProgress: Object.assign((message, event) => progress.report(message, event), {
1214
+ endWait: () => progress.endWait(),
1215
+ }),
1204
1216
  fetch,
1205
1217
  signal,
1206
1218
  }));
1207
1219
  if (parsed.verb === VERB_PREVIEW_PAGE && result?.ok === true) {
1208
1220
  completedPreviewRecovery = normalizePreviewRecovery(result);
1209
1221
  }
1222
+ progress.stop();
1210
1223
  const json = serializeResult(result);
1211
1224
  if (!isValidationInvocation && environment.GITHUB_OUTPUT) {
1212
1225
  await writeGithubActionsOutput(environment.GITHUB_OUTPUT, result);
@@ -1214,6 +1227,7 @@ export async function runCli({
1214
1227
  stdout.write(`${json}\n`);
1215
1228
  return 0;
1216
1229
  } catch (unknownError) {
1230
+ progress?.stop();
1217
1231
  const error = asSiteAuthoringError(unknownError);
1218
1232
  if (completedPreviewRecovery) error.withPreviewRecovery(completedPreviewRecovery);
1219
1233
  const result = failureResult(error);
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Human progress for a long wait, written to stderr (stdout stays the one JSON
3
+ * result). Verbs report plain lines through `report(message)`; a wait reports
4
+ * `report(message, { phase })` once per poll, and this decides how much of that
5
+ * a reader should see.
6
+ *
7
+ * - On a terminal, a wait is one in-place line (spinner, phase, elapsed time)
8
+ * that redraws each second, and a new line starts only when the phase changes.
9
+ * Any other line clears the in-place line first, then the wait line comes back.
10
+ * - Off a terminal (a pipe, CI), no control characters are ever written: one line
11
+ * per phase change, plus a heartbeat at most every `heartbeatMilliseconds` so a
12
+ * long silent phase does not look hung.
13
+ */
14
+ const SPINNER_FRAMES = ["|", "/", "-", "\\"];
15
+ const ERASE_LINE = "\r\u001B[2K";
16
+
17
+ export const PROGRESS_HEARTBEAT_MILLISECONDS = 60_000;
18
+ const REDRAW_MILLISECONDS = 1_000;
19
+
20
+ export function formatElapsed(milliseconds) {
21
+ const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
22
+ const minutes = Math.floor(totalSeconds / 60);
23
+ const seconds = totalSeconds % 60;
24
+ return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
25
+ }
26
+
27
+ /**
28
+ * @param {{
29
+ * stream: { write(text: string): unknown, isTTY?: boolean, columns?: number },
30
+ * interactive?: boolean,
31
+ * quiet?: boolean,
32
+ * now?: () => number,
33
+ * heartbeatMilliseconds?: number,
34
+ * setInterval?: typeof setInterval,
35
+ * clearInterval?: typeof clearInterval,
36
+ * }} options
37
+ */
38
+ export function createProgressReporter({
39
+ stream,
40
+ // A terminal that cannot redraw (TERM=dumb) is handled as a log, like a pipe.
41
+ interactive = stream.isTTY === true,
42
+ quiet = false,
43
+ now = Date.now,
44
+ heartbeatMilliseconds = PROGRESS_HEARTBEAT_MILLISECONDS,
45
+ setInterval: schedule = setInterval,
46
+ clearInterval: cancel = clearInterval,
47
+ }) {
48
+ let phase;
49
+ let phaseStartedAt = 0;
50
+ let waitStartedAt = 0;
51
+ let lastLineAt = 0;
52
+ let frame = 0;
53
+ let inPlace = false;
54
+ let timer;
55
+
56
+ function draw() {
57
+ if (phase === undefined) return;
58
+ frame = (frame + 1) % SPINNER_FRAMES.length;
59
+ const short = phase.replace(/^DEPLOYMENT_STATUS_/u, "");
60
+ const inPhase = formatElapsed(now() - phaseStartedAt);
61
+ const total = formatElapsed(now() - waitStartedAt);
62
+ // One row only: a wrapped line would leave rows the next redraw cannot clear.
63
+ // The phase and both counters are what a reader needs, so the wording around
64
+ // them is what gives way on a narrow terminal.
65
+ const width = Math.max(10, (stream.columns ?? 80) - 1);
66
+ const candidates = [
67
+ `${SPINNER_FRAMES[frame]} Waiting for the deployment (${short}); ${inPhase} in this phase, ${total} total`,
68
+ `${SPINNER_FRAMES[frame]} ${short}: ${inPhase} in phase, ${total} total`,
69
+ `${SPINNER_FRAMES[frame]} ${short} ${inPhase} (${total} total)`,
70
+ ];
71
+ const text = candidates.find((candidate) => candidate.length <= width) ?? candidates.at(-1).slice(0, width);
72
+ stream.write(`${ERASE_LINE}${text}`);
73
+ inPlace = true;
74
+ }
75
+
76
+ function clearInPlace() {
77
+ if (!inPlace) return;
78
+ stream.write(ERASE_LINE);
79
+ inPlace = false;
80
+ }
81
+
82
+ function endWait() {
83
+ if (timer !== undefined) {
84
+ cancel(timer);
85
+ timer = undefined;
86
+ }
87
+ clearInPlace();
88
+ phase = undefined;
89
+ }
90
+
91
+ function line(text) {
92
+ stream.write(`${text}\n`);
93
+ lastLineAt = now();
94
+ }
95
+
96
+ return {
97
+ report(message, event) {
98
+ if (quiet) return;
99
+ if (event?.phase === undefined) {
100
+ // A plain line. On a terminal the wait line is wiped and redrawn by the
101
+ // next tick, so the message is never glued to a half-written spinner.
102
+ clearInPlace();
103
+ line(message);
104
+ if (interactive && phase !== undefined) draw();
105
+ return;
106
+ }
107
+ const at = now();
108
+ if (phase === undefined) waitStartedAt = at;
109
+ const changed = phase !== event.phase;
110
+ if (changed) {
111
+ // The finished phase stays on screen as a line of its own.
112
+ if (interactive && phase !== undefined) {
113
+ clearInPlace();
114
+ line(`Deployment phase ${phase} finished after ${formatElapsed(at - phaseStartedAt)}.`);
115
+ }
116
+ phase = event.phase;
117
+ phaseStartedAt = at;
118
+ }
119
+ if (interactive) {
120
+ draw();
121
+ if (timer === undefined) {
122
+ timer = schedule(draw, REDRAW_MILLISECONDS);
123
+ timer.unref?.();
124
+ }
125
+ return;
126
+ }
127
+ if (changed) {
128
+ line(message);
129
+ } else if (at - lastLineAt >= heartbeatMilliseconds) {
130
+ line(`Still waiting for the deployment (${phase}); ${formatElapsed(at - waitStartedAt)} elapsed.`);
131
+ }
132
+ },
133
+
134
+ /** Ends the wait for a deployment (it finished or failed) while the run carries on. */
135
+ endWait,
136
+
137
+ /** Ends any wait in progress. Safe to call more than once. */
138
+ stop() {
139
+ endWait();
140
+ },
141
+ };
142
+ }
@@ -42,13 +42,16 @@ export const REDIRECT_STATUSES = Object.freeze([301, 302, 307, 308]);
42
42
 
43
43
  /**
44
44
  * Bounds, mirrored from `SiteRedirectMapContract` on the server. The path bound
45
- * is what fits a Workers KV key beside its `redirect:{environment}:{site}:`
46
- * envelope; the target bound is the edge's own.
45
+ * is the map's own; the target bound is the edge's own, and a target must also
46
+ * fit as metadata on its redirect file once encoded.
47
47
  */
48
48
  export const REDIRECT_LIMITS = Object.freeze({
49
49
  entries: 2_000,
50
50
  pathBytes: 400,
51
51
  targetBytes: 2_048,
52
+ // Once percent-encoded, a target rides on its redirect file as object
53
+ // metadata; the server pins the same number (MaxMarkerTargetEncodedLength).
54
+ targetEncodedCharacters: 1_810,
52
55
  });
53
56
 
54
57
  // C0, DEL, and C1 — every code point the server's own `char.IsControl`
@@ -88,8 +91,7 @@ const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
88
91
  * between the API's .NET and the edge's WHATWG parser, so a spelling this pass
89
92
  * encoded would still not be the one the Worker computes and the layers would
90
93
  * go on comparing different strings. Write what a browser sends and every layer
91
- * compares it unchanged — and the byte bounds then measure the spelling the KV
92
- * key actually costs.
94
+ * compares it unchanged — and the byte bounds then measure that same spelling.
93
95
  */
94
96
  function isRequestSpelling(value) {
95
97
  return !UNSENDABLE_SPELLING.test(value);
@@ -154,6 +156,70 @@ export function normalizeRedirectPath(value) {
154
156
  return trimmed === "" ? "/" : trimmed;
155
157
  }
156
158
 
159
+ // The root segment the published output reserves for integration data; the release
160
+ // inventory leaves everything under it out.
161
+ const RESERVED_INTEGRATION_DATA_SEGMENT = "integrations";
162
+
163
+ // Root-level files the site generates. A redirect is a marker file inside the
164
+ // published site (TR00968), so a source that names one has nowhere to live.
165
+ const GENERATED_ROOT_FILES = Object.freeze(["404.html", "sitemap.xml", "robots.txt", "favicon.ico"]);
166
+ // The longest decoded path segment a marker file name may have; the API rule and the
167
+ // generator use the same 200 bytes.
168
+ const MAX_MARKER_SEGMENT_BYTES = 200;
169
+ const WINDOWS_DEVICE_STEMS = new Set(["con", "prn", "aux", "nul"]);
170
+
171
+ function isWindowsDeviceSegment(segment) {
172
+ const stem = segment.split(".", 1)[0].toLowerCase();
173
+ return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
174
+ }
175
+
176
+ /**
177
+ * Why a normalized source path cannot be a redirect, or undefined when it can.
178
+ * A mirror of `SiteRedirectMapContract.SourceRefusalReason` on the server (the
179
+ * API is the authority; both run the same test vectors): `…/index.html` is
180
+ * answered by the platform for every page, the generator writes the files
181
+ * named above, and the marker is stored under the decoded spelling, so a path
182
+ * that cannot be stored once decoded (an escaped percent sign, a dot-prefixed
183
+ * segment, a reserved device name) is refused. The site root is left to the
184
+ * occupancy rule.
185
+ */
186
+ export function redirectSourceRefusalReason(path) {
187
+ if (path === "/") return undefined;
188
+ // Every rule reads the decoded spelling: it is the name the marker is stored
189
+ // under, and the name the generator's own files use (`/%69ndex.html` is
190
+ // `/index.html`).
191
+ let decoded;
192
+ try {
193
+ decoded = path.replace(/^\/+|\/+$/gu, "").split("/").map(decodeURIComponent).join("/");
194
+ } catch {
195
+ return "it holds a percent escape that does not decode.";
196
+ }
197
+ const segments = decoded.split("/");
198
+ const first = segments[0].toLowerCase();
199
+ if (segments.some((segment) => segment.toLowerCase() === "index.html")) {
200
+ return "the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
201
+ + "a redirect source, and the page or home file there is generated.";
202
+ }
203
+ if (first === RESERVED_INTEGRATION_DATA_SEGMENT) {
204
+ return `'/${segments[0]}' is reserved for integration data, so nothing under it can be a redirect source.`;
205
+ }
206
+ if (GENERATED_ROOT_FILES.includes(first)) {
207
+ return `'/${segments[0]}' is a file the site generates, so neither it nor anything under it can be a redirect source.`;
208
+ }
209
+ if (
210
+ decoded.includes("..")
211
+ || decoded !== decoded.normalize("NFC")
212
+ || segments.some((segment) =>
213
+ segment === "" || segment.startsWith(".") || /[%\\?#\u0000-\u001F\u007F-\u009F]/u.test(segment)
214
+ || isWindowsDeviceSegment(segment) || new TextEncoder().encode(segment).length > MAX_MARKER_SEGMENT_BYTES
215
+ )
216
+ ) {
217
+ return "a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
218
+ + "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).";
219
+ }
220
+ return undefined;
221
+ }
222
+
157
223
  function looksAbsolute(value) {
158
224
  return !value.startsWith("/") && value.includes("://");
159
225
  }
@@ -235,14 +301,21 @@ function validateEntry(value, index, seen) {
235
301
  `${field}.path`,
236
302
  );
237
303
  }
238
- if (seen.has(path.toLowerCase())) {
304
+ const sourceRefusal = redirectSourceRefusalReason(path);
305
+ if (sourceRefusal !== undefined) {
306
+ throw entryError("redirects.path_unpublishable", `'${path}': ${sourceRefusal}`, `${field}.path`);
307
+ }
308
+ // The marker is stored under the decoded spelling, so `/a%41` and `/aA` are one
309
+ // file, and the second would silently replace the first.
310
+ const markerKey = decodeURIComponent(path).toLowerCase();
311
+ if (seen.has(markerKey)) {
239
312
  throw entryError(
240
313
  "redirects.path_duplicate",
241
314
  `'${path}' appears more than once; one path has one entry.`,
242
315
  `${field}.path`,
243
316
  );
244
317
  }
245
- seen.add(path.toLowerCase());
318
+ seen.add(markerKey);
246
319
 
247
320
  const kind = value.kind === undefined ? REDIRECT_KIND_REDIRECT : value.kind;
248
321
  if (!REDIRECT_KINDS.includes(kind)) {
@@ -296,6 +369,15 @@ function validateEntry(value, index, seen) {
296
369
  );
297
370
  }
298
371
 
372
+ if (encodeURIComponent(rawTarget).length > REDIRECT_LIMITS.targetEncodedCharacters) {
373
+ throw entryError(
374
+ "redirects.target_too_long_to_store",
375
+ "'target' is stored as metadata on the published site's redirect file and is too long to fit "
376
+ + `(${REDIRECT_LIMITS.targetEncodedCharacters} characters once percent-encoded).`,
377
+ `${field}.target`,
378
+ );
379
+ }
380
+
299
381
  let target;
300
382
  if (looksAbsolute(rawTarget)) {
301
383
  if (!isAbsoluteRedirectTarget(rawTarget)) {
@@ -341,13 +423,23 @@ function validateEntry(value, index, seen) {
341
423
  return { path, kind: REDIRECT_KIND_REDIRECT, target, status };
342
424
  }
343
425
 
426
+ // The marker is stored under the decoded spelling, so `/%61` and `/a` are one route
427
+ // and `/%61 -> /a` is a loop. A target's escape may not decode; it then compares raw.
428
+ function routeIdentity(path) {
429
+ try {
430
+ return decodeURIComponent(path).toLowerCase();
431
+ } catch {
432
+ return path.toLowerCase();
433
+ }
434
+ }
435
+
344
436
  function assertNoChains(entries) {
345
- const sources = new Set(entries.map((entry) => entry.path.toLowerCase()));
437
+ const sources = new Set(entries.map((entry) => routeIdentity(entry.path)));
346
438
  entries.forEach((entry, index) => {
347
439
  const targetPath = internalRedirectTargetPath(entry);
348
- if (targetPath === undefined || !sources.has(targetPath.toLowerCase())) return;
440
+ if (targetPath === undefined || !sources.has(routeIdentity(targetPath))) return;
349
441
  const field = `entries[${index}].target`;
350
- if (targetPath.toLowerCase() === entry.path.toLowerCase()) {
442
+ if (routeIdentity(targetPath) === routeIdentity(entry.path)) {
351
443
  throw entryError("redirects.loop", `'${entry.path}' redirects to itself.`, field);
352
444
  }
353
445
  throw entryError(
@@ -602,8 +602,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
602
602
  + "a separate staging handoff and checks the current map at the real edge, using an in-memory cookie and "
603
603
  + "manual redirects. It reports each path, HTTP status and Location without following targets.",
604
604
  "verified is true only if every entry matched and the map revision stayed unchanged during the check. "
605
- + "The check proves the current map at staging, not a historical deployment. A mismatch can be propagation "
606
- + "delay; re-run redirects check before promoting. Requests are bounded to 90 seconds, eight at a time; "
605
+ + "The check proves the current map at staging, not a historical deployment. A mismatch means the staged "
606
+ + "release does not carry the entry (yet); re-run redirects check after the deployment completes, before promoting. Requests are bounded to 90 seconds, eight at a time; "
607
607
  + "large JSON lists are truncated explicitly with failed entries first. Human output reports every entry.",
608
608
  "stagingPreview.url is a fresh, single-use two-minute handoff, minted after checks. It is emitted only "
609
609
  + "in final JSON, excluded from progress and GITHUB_OUTPUT; keep it private. The staging cookie lasts "
@@ -654,10 +654,15 @@ const WORKFLOW_REFERENCES = Object.freeze({
654
654
  "Converting an engagement's CSV into this file is the agent's job. The CLI's contract is JSON, on "
655
655
  + "purpose: a real migration list needs judgement about which old URLs still deserve a destination and "
656
656
  + "which are simply gone.",
657
- "Entries take effect on the next deploy, staging first. They are written to the edge's key-value store "
658
- + "when the deploy syncs routing, and that store is eventually consistent, so a spot-check run "
659
- + "immediately afterwards can briefly still see the previous map. Re-check before concluding an entry "
660
- + "did not land.",
657
+ "Entries take effect on the next deploy, staging first. Each is a file inside that release, so it goes live "
658
+ + "exactly when the release does: staging with the staged release, production when it is promoted. There is "
659
+ + "no separate propagation delay; an entry a check does not see belongs to a release that is not being "
660
+ + "served yet.",
661
+ "A source cannot name a file the site generates: '/index.html' or any page's own 'index.html', '/404.html', "
662
+ + "'/sitemap.xml', '/robots.txt', '/favicon.ico', or a path under the sitemap or robots names. The platform "
663
+ + "already redirects every '…/index.html' address to its directory, so no entry is needed for one. A source "
664
+ + "that could not be stored as a file (a dot-prefixed segment, a reserved device name, an escaped percent "
665
+ + "sign) is refused too. 'validate' names the entry and the reason.",
661
666
  ]),
662
667
  example: Object.freeze({
663
668
  siteId: "<site-uuid>",
@@ -27,24 +27,18 @@ import { ApiError } from "../transport.js";
27
27
  import { readManifest, writeManifest } from "../workspace.js";
28
28
 
29
29
  /**
30
- * What a deploy does not finish (TR00702).
30
+ * When a redirect goes live (TR00702, TR00968).
31
31
  *
32
- * A deploy writes the site's redirect and gone entries into the edge's
33
- * key-value store as part of syncing routing. That store is eventually
34
- * consistent, so a spot-check run the second a deploy reports success can
35
- * briefly read the previous map, and taking that for "the redirect did not
36
- * land" is the wrong conclusion to reach in front of a customer waiting to cut
37
- * DNS over.
38
- *
39
- * No number is quoted, because there is none to quote: the coordinator holds
40
- * nothing back for a standard site's redirects. Its propagation grace governs
41
- * only the *deletion* of a superseded Docs pointer namespace; a standard site's
42
- * rows are written and removed immediately.
32
+ * A redirect or gone entry is a file inside the release this deploy publishes.
33
+ * It goes live with that release and no other way: staging shows it as soon as
34
+ * the staged release is served, production shows it when the release is
35
+ * promoted. There is no separate store to wait on, so a check that does not see
36
+ * an entry is looking at a release that does not carry it yet.
43
37
  */
44
- const REDIRECT_PROPAGATION_NOTE =
45
- "Redirect and gone entries are written to the edge's key-value store when this deploy syncs routing. That "
46
- + "store is eventually consistent, so a spot-check run immediately afterwards can briefly still see the "
47
- + "previous map; re-check before concluding an entry is missing.";
38
+ const REDIRECT_ACTIVATION_NOTE =
39
+ "Redirect and gone entries live inside the release this deploy publishes: staging serves them with the staged "
40
+ + "release and production serves them once that release is promoted. There is no separate propagation delay; "
41
+ + "an entry a check does not see belongs to a release that is not being served yet.";
48
42
 
49
43
  /**
50
44
  * `deploy --staging` / `deploy --production` — readiness, deploy, poll to
@@ -158,7 +152,7 @@ async function inspectStagingPreview(client, siteId, onProgress, now) {
158
152
  checks = await checkStagingRedirects(client, siteId, onProgress, now);
159
153
  if (!checks.redirects.verified) {
160
154
  onProgress(
161
- "Warning: staging redirects are not all verified. Edge propagation may still be pending; run 'taproot-site redirects check' again.",
155
+ "Warning: staging redirects are not all verified. Check the reported mismatches against the staged release; run 'taproot-site redirects check' again once the deployment has completed.",
162
156
  );
163
157
  }
164
158
  } catch {
@@ -288,7 +282,7 @@ export async function deploy(invocation) {
288
282
  now,
289
283
  });
290
284
  await recordDeployment(config, manifest, "production", completed);
291
- if (!presentationOnly) onProgress(REDIRECT_PROPAGATION_NOTE);
285
+ if (!presentationOnly) onProgress(REDIRECT_ACTIVATION_NOTE);
292
286
  return successResult(VERB_DEPLOY, siteId, {
293
287
  target,
294
288
  environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
@@ -324,16 +318,22 @@ export async function deploy(invocation) {
324
318
  const stagedPageIds = presentationOnly ? [] : selection.stagedPageIds ?? defaults.stagedPageIds;
325
319
  const selectedSettingsTypes = selection.selectedSettingsTypes ?? defaults.selectedSettingsTypes;
326
320
  const includeNavigation = presentationOnly ? false : selection.includeNavigation ?? defaults.includeNavigation;
327
- if (stagedPageIds.length === 0 && selectedSettingsTypes.length === 0 && !includeNavigation) {
328
- throw new SiteAuthoringError(
321
+ const nothingSelected = stagedPageIds.length === 0 && selectedSettingsTypes.length === 0 && !includeNavigation;
322
+ // A managed Docs site stages settings only, so an empty selection is final.
323
+ // Any other site may still have a publishable change no selection names: an
324
+ // edited redirect map. Readiness below says whether it does.
325
+ const emptySelectionError = () =>
326
+ new SiteAuthoringError(
329
327
  "deploy.empty_selection",
330
328
  presentationOnly
331
329
  ? "A staging deployment on a managed Docs site needs at least one settings change; it stages settings "
332
330
  + "only. Change a theme, brand, header, or footer setting first."
333
- : "A staging deployment needs at least one approved page, settings group, or navigation change. "
334
- + "Run 'taproot-site approve' or change staged settings/navigation first.",
331
+ : "A staging deployment needs at least one approved page, settings group, or navigation change, or an "
332
+ + "edited redirect map. Run 'taproot-site approve' or change staged settings/navigation first.",
335
333
  { field: "Candidate" },
336
334
  );
335
+ if (nothingSelected && presentationOnly) {
336
+ throw emptySelectionError();
337
337
  }
338
338
 
339
339
  const candidate = {
@@ -359,6 +359,9 @@ export async function deploy(invocation) {
359
359
  { field: readiness.blockers[0].imageId || "blockers", status: readiness.blockers[0].state },
360
360
  );
361
361
  }
362
+ if (nothingSelected && !readiness.hasCandidateChanges) {
363
+ throw emptySelectionError();
364
+ }
362
365
  if (!readiness.hasCandidateChanges) {
363
366
  onProgress("Taproot reports this candidate contains no changes; deploying it anyway would be a no-op.");
364
367
  }
@@ -398,7 +401,7 @@ export async function deploy(invocation) {
398
401
  });
399
402
  }
400
403
  const stagingPreview = await inspectStagingPreview(client, siteId, onProgress, now);
401
- onProgress(REDIRECT_PROPAGATION_NOTE);
404
+ onProgress(REDIRECT_ACTIVATION_NOTE);
402
405
  return successResult(VERB_DEPLOY, siteId, {
403
406
  target,
404
407
  environment: DEPLOYMENT_ENVIRONMENT_STAGING,
@@ -9,7 +9,7 @@ export async function redirectsCheck(invocation) {
9
9
  const checks = await checkStagingRedirects(client, siteId, onProgress, now);
10
10
  if (!checks.redirects.verified) {
11
11
  onProgress(
12
- "Warning: staging redirects are not verified. Check the reported mismatches and retry after edge propagation.",
12
+ "Warning: staging redirects are not verified. Check the reported mismatches against the staged release and retry once its deployment has completed.",
13
13
  );
14
14
  }
15
15
  const handoff = await mintStagingPreviewHandoff(client, siteId, { now });