Skip to content

Commit bacee1a

Browse files
committed
review(#152/#153): incorporate code-review feedback (2 HIGH + 3 MEDIUM + LOW polish)
Independent reviews of PR #196 (imports kind) and PR #199 (R2 substrate) surfaced two real bugs in the substrate's S3 path plus several MEDIUM-severity correctness issues. This change closes them all and adds regression tests for each. **HIGH** — `pipeline/refresh-index.mjs:147,154`: `require('node:fs').unlinkSync(tmp)` inside an `.mjs` ESM module throws `ReferenceError`, silently swallowed by the surrounding `try{}catch{}`. Every S3 GET / PUT leaked a `/tmp/refresh-index-*` file in production. Hermetic tests didn't exercise the S3 path so the bug never surfaced. Fix: hoist `unlinkSync` into the top-level `import { ... } from 'node:fs'`. **HIGH** — `pipeline/refresh-index.mjs` S3 `putBytes`: the file's own header (line 35) declares `If-Match on the current ETag; retry on 412`, and brief §2.5 / §3.4 / §7.2 prescribe ETag-CAS. The implementation was a bare `s3api put-object` with no preconditions and no retry — two concurrent publishes would silently lose one writer's view. Fix: add `headETag()` to the S3 backend; `putBytes({ifMatch})` passes `--if-match` and translates 412 to a tagged `__precondition` error; the write loop reads the ETag, attempts the PUT, and on precondition failure retries with capped exponential backoff (up to 5 attempts). **MEDIUM** — `pipeline/refresh-index.mjs`: when a newer SHA-keyed prefix exists in the bucket but `latest.json` still points at the older one (interrupted publish, partial restore, manual upload), the emitted index record mixed `prefix` and `short_sha` from the bucket-derived newest with `commit_sha` and `published_at` read from the stale pointer. The mismatched `published_at` also poisoned the `status` calculation. Fix: trust `latest.json`'s metadata only when its `prefix` field actually matches the chosen `latestPrefix`. Otherwise log a WARN, derive `short_sha` + `published_at` from the prefix directory name, and emit `commit_sha: null`. New `refresh-index.mjs: latest.json points at older prefix than bucket newest (drift)` test exercises this. **MEDIUM** — `pipeline/publish-catalog.sh`: validation and upload were interleaved in a single `while ... | read` loop. If catalog A validated and uploaded, then catalog B failed validation, the script exited 1 leaving A as an orphan under the SHA-keyed prefix with no `latest.json` ever written — and the next `refresh-index` run would treat the orphan as the canonical "latest." Fix: split into a validate-all-first pass (record failure to a tempfile so the subshell `exit` doesn't get swallowed) then an upload-all-after pass; the bucket stays untouched if any catalog fails validation. New `publish-catalog.sh: mixed-validity batch uploads NOTHING (no orphan SHA prefix)` test exercises this. **MEDIUM** — `pipeline/fetch-catalogs.sh`: the `distinct_repos` counter was incremented for every repo named in the work list (i.e., every ok-status repo in the index) regardless of whether any catalog was actually present in the cache afterwards. An index with 3 ok repos where all GETs failed would exit 0 with an empty cache and `Summary: 3 repos / 0 fetched / 0 cached / 3 failed`. Downstream `jq -s 'map(.entries) | add'` then silently produced empty output. Fix: rename the counter to `present_repos` and only `echo` to its tempfile after a successful cache hit or fetch. New `fetch-catalogs.sh: exits 1 when every fetch fails` test exercises this. **LOW polish** rolled into the same change: - `extractors/typescript/type-catalog.mjs:524`: `const { a: { b } } = require('pkg')` previously emitted zero rows (every element was `continue`d as a nested binding, then the explicit `return` short-circuited the bare-call fallback) so the consumer edge to the package was completely lost. Now falls through to the namespace-form `pushRequire({ name: '*', imported_as: null })` so the package edge survives. Fixture `12-nested-require.ts` + matching assertion added. - `extractors/typescript/type-catalog.mjs:467`: `import {} from "pkg"` (empty named-imports clause — semantically a side-effect import) now emits a side-effect row instead of zero rows. Fixture `11-empty-named.ts` + matching assertion added. - `extractors/typescript/type-catalog.mjs:404-409`: corrected the misleading "interleave with declaration rows by line" comment to describe actual ordering (top-level imports first in source-line order; declarations + dynamic-import + require rows after, in AST-walk order). Both halves remain deterministic, which is what the contract depends on. - `pipeline/refresh-index.mjs`: `--bucket-fs DIR` now preflight-checks the directory exists with a friendly error message instead of crashing with a raw `ENOENT mkdir` stack inside `putBytes`. - `pipeline/refresh-index.mjs:196`: dropped the hardcoded `wxyc/` org prefix in the canonical-name fallback. When `latest.json` is missing or unparseable, `canonicalRepo` now falls back to `pathSegment` alone — keeps the substrate org-agnostic. Verified: 97 extractor tests + 26 canonical + 124 integration + 54 gojq-parity + 56 substrate = 357 tests pass. shellcheck on the four substrate shell scripts is clean. Refs #152, #153.
1 parent 7399abb commit bacee1a

8 files changed

Lines changed: 267 additions & 49 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// `import {} from "pkg"` — empty named-imports clause. Semantically a
2+
// side-effect import; the consumer edge to "@wxyc/empty-named" must still
3+
// be recorded.
4+
import {} from '@wxyc/empty-named';
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// `const { a: { b } } = require('pkg')` — every destructured binding is a
2+
// nested pattern. The previous extractor lost the specifier entirely;
3+
// after the fix it falls through to the namespace-form bare require row.
4+
const { a: { b } } = require('@wxyc/nested-require');
5+
6+
export const _b = b;

extractors/typescript/test/imports.test.mjs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ test('with --include-imports, entry count grows by exactly the expected import c
6363
const cat = catalogWithImports();
6464
const baseline = catalogWithoutImports();
6565
const importRows = cat.entries.filter((e) => e.kind === 'import');
66-
assert.equal(importRows.length, 26, `unexpected import row count: ${importRows.length}`);
67-
assert.equal(cat.entries.length, baseline.entries.length + 26);
66+
assert.equal(importRows.length, 28, `unexpected import row count: ${importRows.length}`);
67+
assert.equal(cat.entries.length, baseline.entries.length + 28);
6868
});
6969

7070
test('filtering out kind:"import" matches the no-flag catalog byte-for-byte', () => {
@@ -252,6 +252,33 @@ test('relative and absolute specifiers record origin_package=null, resolution="r
252252
]);
253253
});
254254

255+
test('empty-named: `import {} from "pkg"` emits a single side-effect row', () => {
256+
// Empty named-imports clause is semantically equivalent to a side-effect
257+
// import — the consumer-edge to the package must still be recorded.
258+
const rows = importsFor(catalogWithImports(), '11-empty-named.ts');
259+
assert.deepEqual(rows, [
260+
{
261+
name: null, imported_as: null, import_form: 'side-effect',
262+
origin_specifier: '@wxyc/empty-named', origin_package: '@wxyc/empty-named',
263+
origin_resolution: 'bare-specifier', type_only: false,
264+
},
265+
]);
266+
});
267+
268+
test('nested-require: all-nested destructure falls through to namespace require so the package edge survives', () => {
269+
// `const { a: { b } } = require('pkg')` — every element is a nested
270+
// binding pattern. Previously dropped silently; now records the package
271+
// edge with name="*" and no local alias.
272+
const rows = importsFor(catalogWithImports(), '12-nested-require.ts');
273+
assert.deepEqual(rows, [
274+
{
275+
name: '*', imported_as: null, import_form: 'require',
276+
origin_specifier: '@wxyc/nested-require', origin_package: '@wxyc/nested-require',
277+
origin_resolution: 'bare-specifier', type_only: false,
278+
},
279+
]);
280+
});
281+
255282
test('mixed: default + named bindings from one statement emit in source order', () => {
256283
const rows = importsFor(catalogWithImports(), '10-mixed.ts');
257284
assert.deepEqual(rows, [

extractors/typescript/type-catalog.mjs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -402,9 +402,12 @@ function extractFromFile(filePath, pkgName, pkgRoot, { collectImports, includeIm
402402
const rawImports = collectImports ? [] : null;
403403

404404
// Per-symbol `kind: "import"` rows for the type-catalog (gated on
405-
// --include-imports). One row per imported binding; pushed in walk order
406-
// so they interleave with declaration rows by line — keeps the catalog's
407-
// existing "walk order = output order" determinism contract intact.
405+
// --include-imports). Top-level static imports + re-exports are emitted
406+
// here, ahead of visit(sf) — so within a file the row order is:
407+
// 1) every top-level import/re-export in source-line order, then
408+
// 2) declaration + dynamic-import + require rows in AST-walk order.
409+
// Both halves are deterministic, which is what the catalog contract
410+
// actually depends on.
408411
function emitImportRowsForDecl(stmt, specifier, importForm) {
409412
const declTypeOnly = importForm === 're-export'
410413
? stmt.isTypeOnly === true
@@ -447,12 +450,15 @@ function extractFromFile(filePath, pkgName, pkgRoot, { collectImports, includeIm
447450
pushImport({ name: null, imported_as: null, import_form: 'side-effect', type_only: false });
448451
return;
449452
}
453+
let emittedAny = false;
450454
if (clause.name) {
451455
pushImport({ name: 'default', imported_as: clause.name.text, import_form: 'default', type_only: declTypeOnly });
456+
emittedAny = true;
452457
}
453458
const nb = clause.namedBindings;
454459
if (nb && ts.isNamespaceImport(nb)) {
455460
pushImport({ name: '*', imported_as: nb.name.text, import_form: 'namespace', type_only: declTypeOnly });
461+
emittedAny = true;
456462
} else if (nb && ts.isNamedImports(nb)) {
457463
for (const spec of nb.elements) {
458464
const localName = spec.name.text;
@@ -463,8 +469,15 @@ function extractFromFile(filePath, pkgName, pkgRoot, { collectImports, includeIm
463469
import_form: 'named',
464470
type_only: declTypeOnly || spec.isTypeOnly === true,
465471
});
472+
emittedAny = true;
466473
}
467474
}
475+
// `import {} from "pkg"` parses as a named-imports clause with zero
476+
// elements — semantically equivalent to a side-effect import. Treat it
477+
// as such so the consumer edge to `pkg` is still recorded.
478+
if (!emittedAny) {
479+
pushImport({ name: null, imported_as: null, import_form: 'side-effect', type_only: false });
480+
}
468481
}
469482

470483
// Dynamic `import(spec)` and `require(spec)` rows. Both live inside visit()
@@ -504,17 +517,21 @@ function extractFromFile(filePath, pkgName, pkgRoot, { collectImports, includeIm
504517
const parent = callNode.parent;
505518
if (parent && ts.isVariableDeclaration(parent)) {
506519
if (ts.isObjectBindingPattern(parent.name)) {
520+
let emittedAny = false;
507521
for (const el of parent.name.elements) {
508522
if (!ts.isIdentifier(el.name)) continue; // skip nested binding patterns in v1
509523
const localName = el.name.text;
510524
const originName = el.propertyName && ts.isIdentifier(el.propertyName)
511525
? el.propertyName.text
512526
: localName;
513527
pushRequire({ name: originName, imported_as: localName });
528+
emittedAny = true;
514529
}
515-
return;
516-
}
517-
if (ts.isIdentifier(parent.name)) {
530+
if (emittedAny) return;
531+
// Destructure consists entirely of nested binding patterns
532+
// (`const {a:{b}} = require('pkg')`). Fall through to the bare-call
533+
// form so at least the package consumer edge is recorded.
534+
} else if (ts.isIdentifier(parent.name)) {
518535
pushRequire({ name: '*', imported_as: parent.name.text });
519536
return;
520537
}

pipeline/_tests/test_substrate.sh

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,19 @@ assert_nonzero_exit "$rc" "malformed index exits nonzero"
142142
assert_contains "$out" "malformed index.json" "error message mentions malformed"
143143
rm -rf "$SCRATCH" "$BAD_MOCK"
144144

145+
echo "=== fetch-catalogs.sh: exits 1 when every fetch fails (regression for review feedback) ==="
146+
SCRATCH=$(mk_scratch)
147+
DEAD_MOCK=$(mk_scratch)
148+
# Build a "bucket" that has an index pointing at keys that don't exist.
149+
cp "$MOCK/index.json" "$DEAD_MOCK/"
150+
# Intentionally omit by-repo/* — every GET will fail.
151+
out=$(AUDIT_BUCKET_URL="file://$DEAD_MOCK" AUDIT_LOCAL_CACHE="$SCRATCH" \
152+
bash "$PIPELINE/fetch-catalogs.sh" 2>&1)
153+
rc=$?
154+
assert_nonzero_exit "$rc" "all-failed fetch exits nonzero"
155+
assert_contains "$out" "0 repos present" "summary reports zero present repos"
156+
rm -rf "$SCRATCH" "$DEAD_MOCK"
157+
145158
echo "=== fetch-catalogs.sh: sha256 mismatch handled ==="
146159
SCRATCH=$(mk_scratch)
147160
TAMPER_MOCK=$(mk_scratch)
@@ -192,6 +205,45 @@ assert_eq "0d733bd04c183bd79e59774d8e9a7cf20cd46463abfb8f8489491fb8030dce63" "$a
192205
"rebuilt sha256 matches the canonical fixture value"
193206
rm -rf "$SCRATCH"
194207

208+
echo "=== refresh-index.mjs: --bucket-fs preflight rejects nonexistent dir ==="
209+
out=$(node "$PIPELINE/refresh-index.mjs" --bucket-fs /tmp/does-not-exist-xyz123 2>&1)
210+
rc=$?
211+
assert_nonzero_exit "$rc" "nonexistent dir exits nonzero"
212+
assert_contains "$out" "not a directory" "error message names the missing dir"
213+
214+
echo "=== refresh-index.mjs: latest.json points at older prefix than bucket newest (drift) ==="
215+
SCRATCH=$(mk_scratch)
216+
cp -r "$MOCK/." "$SCRATCH/"
217+
rm -f "$SCRATCH/index.json"
218+
# Plant a future snapshot under fake-repo-a (must use hex chars for the short_sha).
219+
mkdir -p "$SCRATCH/by-repo/fake-repo-a/2099-12-31T23-59-59Z_aabbcc1"
220+
cat > "$SCRATCH/by-repo/fake-repo-a/2099-12-31T23-59-59Z_aabbcc1/type-catalog.json" <<'JSON'
221+
{"schema_version":"1.1","extractor":{"language":"typescript","name":"type-catalog","version":"0.5.0"},"entries":[]}
222+
JSON
223+
# Don't update latest.json — that's the drift scenario.
224+
out=$(node "$PIPELINE/refresh-index.mjs" --bucket-fs "$SCRATCH" 2>&1)
225+
assert_contains "$out" "but bucket newest is" "drift warning surfaced"
226+
prefix=$(jq -r '.repos[] | select(.path_segment == "fake-repo-a") | .latest.prefix' "$SCRATCH/index.json")
227+
assert_eq "by-repo/fake-repo-a/2099-12-31T23-59-59Z_aabbcc1/" "$prefix" \
228+
"rebuilt prefix reflects bucket newest (not stale latest.json)"
229+
short_sha=$(jq -r '.repos[] | select(.path_segment == "fake-repo-a") | .latest.short_sha' "$SCRATCH/index.json")
230+
assert_eq "aabbcc1" "$short_sha" "short_sha derived from prefix directory name"
231+
commit_sha=$(jq -r '.repos[] | select(.path_segment == "fake-repo-a") | .latest.commit_sha' "$SCRATCH/index.json")
232+
assert_eq "null" "$commit_sha" "commit_sha null when stale latest.json can't be trusted"
233+
rm -rf "$SCRATCH"
234+
235+
echo "=== refresh-index.mjs: canonical repo name falls back to path-segment alone (no org guess) ==="
236+
SCRATCH=$(mk_scratch)
237+
mkdir -p "$SCRATCH/by-repo/org-less-repo/2026-05-30T10-00-00Z_aaa1111"
238+
cat > "$SCRATCH/by-repo/org-less-repo/2026-05-30T10-00-00Z_aaa1111/type-catalog.json" <<'JSON'
239+
{"schema_version":"1.1","extractor":{"language":"typescript","name":"type-catalog","version":"0.5.0"},"entries":[]}
240+
JSON
241+
# Intentionally no latest.json — forces the fallback path.
242+
node "$PIPELINE/refresh-index.mjs" --bucket-fs "$SCRATCH" >/dev/null 2>&1
243+
canonical=$(jq -r '.repos[0].repo' "$SCRATCH/index.json")
244+
assert_eq "org-less-repo" "$canonical" "canonical name == path_segment when latest.json absent (no wxyc/ guess)"
245+
rm -rf "$SCRATCH"
246+
195247
echo "=== refresh-index.mjs: --known-repos surfaces missing repos ==="
196248
SCRATCH=$(mk_scratch)
197249
cp -r "$MOCK/." "$SCRATCH/"
@@ -311,6 +363,29 @@ assert_nonzero_exit "$rc" "bare-array publish exits nonzero"
311363
assert_contains "$out" "REFUSED" "refusal message emitted"
312364
rm -rf "$PUB_BUCKET" "$PUB_CAT"
313365

366+
echo "=== publish-catalog.sh: mixed-validity batch uploads NOTHING (no orphan SHA prefix) ==="
367+
PUB_BUCKET=$(mk_scratch)
368+
PUB_CAT=$(mk_scratch)
369+
# One good catalog, one bare-array (refused). Pre-fix: the good one
370+
# uploaded before the bad one's validation failed — leaving an orphan.
371+
cat > "$PUB_CAT/type-catalog.json" <<'JSON'
372+
{"schema_version":"1.1","extractor":{"language":"typescript","name":"type-catalog","version":"0.5.0"},"entries":[]}
373+
JSON
374+
printf '[{"name":"x"}]\n' > "$PUB_CAT/function-catalog.json"
375+
out=$(bash "$PIPELINE/publish-catalog.sh" \
376+
--repo wxyc/mixed --sha bbbbbbb1234567 --catalogs-dir "$PUB_CAT" \
377+
--bucket-fs "$PUB_BUCKET" --skip-refresh 2>&1)
378+
rc=$?
379+
assert_nonzero_exit "$rc" "mixed-batch publish exits nonzero"
380+
if [ -d "$PUB_BUCKET/by-repo/wxyc-mixed" ]; then
381+
FAIL=$((FAIL + 1))
382+
echo " ✗ orphan SHA prefix created in the bucket"
383+
else
384+
PASS=$((PASS + 1))
385+
echo " ✓ bucket untouched on validation failure"
386+
fi
387+
rm -rf "$PUB_BUCKET" "$PUB_CAT"
388+
314389
echo "=== publish-catalog.sh: --skip-refresh leaves index alone ==="
315390
PUB_BUCKET=$(mk_scratch)
316391
PUB_CAT=$(mk_scratch)

pipeline/fetch-catalogs.sh

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -155,21 +155,24 @@ fi
155155
new_count=0
156156
cached_count=0
157157
fail_count=0
158-
seen_repos_file="$cache_dir/.fetch-seen-repos.tmp"
159-
: > "$seen_repos_file"
158+
# Only repos with at least one cached-or-fetched catalog count as "present
159+
# in cache." A repo whose every catalog fails to fetch does NOT count —
160+
# otherwise the script would exit 0 with an empty cache and downstream
161+
# `jq -s 'map(.entries) | add' */latest.json` would silently produce zero
162+
# results.
163+
present_repos_file="$cache_dir/.fetch-present-repos.tmp"
164+
: > "$present_repos_file"
160165

161166
log "[2/3] Fetching catalog objects from $bucket_url"
162167

163168
while IFS=$'\t' read -r repo key expected_sha; do
164-
echo "$repo" >> "$seen_repos_file"
165-
# Flatten path-segment: index already maps repo -> path_segment, but the
166-
# key contains the segment, so we can just store under cache_dir/<key>.
167169
dest="$cache_dir/$key"
168170
if [ -f "$dest" ]; then
169171
actual_sha=$(sha256_of "$dest")
170172
if [ "$actual_sha" = "$expected_sha" ]; then
171173
log " cached $repo $(basename "$key")"
172174
cached_count=$((cached_count + 1))
175+
echo "$repo" >> "$present_repos_file"
173176
continue
174177
fi
175178
fi
@@ -178,6 +181,7 @@ while IFS=$'\t' read -r repo key expected_sha; do
178181
if [ "$actual_sha" = "$expected_sha" ]; then
179182
log " fetched $repo $(basename "$key")"
180183
new_count=$((new_count + 1))
184+
echo "$repo" >> "$present_repos_file"
181185
else
182186
echo " SHA MISMATCH $repo $(basename "$key"): index=$expected_sha actual=$actual_sha" >&2
183187
rm -f "$dest"
@@ -191,13 +195,13 @@ done <<EOF
191195
$work_list
192196
EOF
193197

194-
distinct_repos=$(sort -u "$seen_repos_file" | wc -l | tr -d ' ')
195-
rm -f "$seen_repos_file"
198+
present_repos=$(sort -u "$present_repos_file" | wc -l | tr -d ' ')
199+
rm -f "$present_repos_file"
196200

197-
log "[3/3] Summary: $distinct_repos repos / $new_count fetched / $cached_count cached / $fail_count failed"
201+
log "[3/3] Summary: $present_repos repos present / $new_count fetched / $cached_count cached / $fail_count failed"
198202
log "Cache: $cache_dir"
199203

200-
if [ "$distinct_repos" -eq 0 ]; then
204+
if [ "$present_repos" -eq 0 ]; then
201205
exit 1
202206
fi
203207
exit 0

pipeline/publish-catalog.sh

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,18 @@ if [ -z "$catalog_files" ]; then
120120
exit 1
121121
fi
122122

123-
echo "[1/3] Validating and uploading catalogs..." >&2
124-
125-
# IFS protects against catalog names with spaces (shouldn't happen but cheap).
123+
echo "[1/4] Validating every catalog before any upload..." >&2
124+
125+
# Validate all files first — only after every catalog passes do we touch
126+
# the bucket. Prevents the orphan-upload case where file A validates+uploads
127+
# then file B fails validation, leaving A stranded under the SHA prefix
128+
# with no latest.json. The subshell `exit 1` inside the pipe-then-read loop
129+
# wouldn't terminate the script, so we record failure to a tempfile and
130+
# check it after the loop.
131+
val_status="/tmp/publish-catalog-val.$$.status"
132+
: > "$val_status"
126133
echo "$catalog_files" | while IFS= read -r path; do
127134
filename=$(basename "$path")
128-
kind="${filename%.json}"
129-
# Schema check: must be the v1.1 wrapper object with the required fields.
130135
if ! jq -e '
131136
type == "object"
132137
and (.schema_version | type == "string")
@@ -136,15 +141,26 @@ echo "$catalog_files" | while IFS= read -r path; do
136141
or (.nodes | type == "array"))
137142
' "$path" >/dev/null 2>&1; then
138143
echo " REFUSED: $filename is not a v1.1 wrapper-shaped catalog" >&2
139-
exit 1
144+
echo "fail" >> "$val_status"
140145
fi
146+
done
147+
if [ -s "$val_status" ]; then
148+
rm -f "$val_status"
149+
echo "publish-catalog.sh: validation failed; nothing uploaded" >&2
150+
exit 1
151+
fi
152+
rm -f "$val_status"
153+
154+
echo "[2/4] Uploading catalogs..." >&2
155+
echo "$catalog_files" | while IFS= read -r path; do
156+
filename=$(basename "$path")
141157
upload "$path" "${prefix}/${filename}"
142158
echo " uploaded ${prefix}/${filename}" >&2
143159
done
144160

145-
# We re-derive metadata in a second pass because the `while ... | read` loop
146-
# above runs in a subshell, so any vars set in there don't survive.
147-
echo "[2/3] Building latest.json pointer..." >&2
161+
# We re-derive metadata in a separate pass because the `while ... | read`
162+
# loop above runs in a subshell, so any vars set in there don't survive.
163+
echo "[3/4] Building latest.json pointer..." >&2
148164
echo "$catalog_files" | while IFS= read -r path; do
149165
filename=$(basename "$path")
150166
kind="${filename%.json}"
@@ -173,7 +189,7 @@ upload "$latest_tmp" "by-repo/${path_segment}/latest.json"
173189
echo " wrote by-repo/${path_segment}/latest.json" >&2
174190
rm -f "$latest_tmp" "/tmp/publish-catalog-meta.$$.txt"
175191

176-
echo "[3/3] Refreshing index.json..." >&2
192+
echo "[4/4] Refreshing index.json..." >&2
177193
if [ "$skip_refresh" -eq 1 ]; then
178194
echo " skipped (--skip-refresh)" >&2
179195
else

0 commit comments

Comments
 (0)