fix: base CHANGELOG read in readFragments fails loudly; history readers try git show first

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 19:10:03 +00:00
1 parent 48a9d92a2f
commit 6aac45aa74
4 files changed
+37 -23

No files matched your search

+12 -11
View File
@@ -6,14 +6,14 @@
#
# Usage: history-read.sh <ref> <path> [--absent <text>]
#
# Existence comes from the ref's tree (`git ls-tree --full-tree`, local data only). A path
# the tree does not list is absent: prints <text> (default: nothing), exits 0.
# A listed path is read with `git show`, tried 3 times in a partial clone
# (only a fetch can recover) and once otherwise, waiting
# HISTORY_READ_BACKOFF_SECONDS (default 2) times the attempt between tries.
# Anything else (a ref that does not resolve, a fetch that keeps failing) exits
# 1 with HISTORY_READ_FAILED naming the ref, the path and the git version, and
# never reads as absent.
# A read that `git show` answers is the file. When it fails, existence comes
# from the ref's tree (`git ls-tree --full-tree`, local data only): a path the
# tree does not list is absent, so this prints <text> (default: nothing) and
# exits 0. A listed path is retried, 3 tries in all in a partial clone (only a
# fetch can recover) and none otherwise, waiting HISTORY_READ_BACKOFF_SECONDS
# (default 2) times the attempt between tries. Anything else (a ref that does
# not resolve, a fetch that keeps failing) exits 1 with HISTORY_READ_FAILED
# naming the ref, the path and the git version, and never reads as absent.
# docs/troubleshooting.md#history-read-failed
set -euo pipefail
@@ -30,6 +30,7 @@ fail() {
exit 1
}
if git show "${ref}:${file}" >"$out" 2>"$err"; then cat "$out"; exit 0; fi
listed=$(git ls-tree --full-tree "$ref" -- "$file" 2>"$err") || fail "git ls-tree failed: $(head -n 1 "$err")"
if [ -z "$listed" ]; then
printf '%s' "$absent"
@@ -39,9 +40,9 @@ fi
tries=1
if git config --get-regexp '^(remote\..+\.promisor|extensions\.partialclone)$' | grep -qvi ' false$'; then tries=3; fi
try=1
until git show "${ref}:${file}" >"$out" 2>"$err"; do
[ "$try" -lt "$tries" ] || fail "git show failed ${tries} time(s): $(head -n 1 "$err")"
while [ "$try" -lt "$tries" ]; do
sleep "$(( try * ${HISTORY_READ_BACKOFF_SECONDS:-2} ))"
if git show "${ref}:${file}" >"$out" 2>"$err"; then cat "$out"; exit 0; fi
try=$(( try + 1 ))
done
cat "$out"
fail "git show failed ${tries} time(s): $(head -n 1 "$err")"
+3 -2
View File
@@ -9,6 +9,7 @@
*/
import { spawnSync } from 'node:child_process';
import { headingVersion, parseChangelog } from './changelog-check';
import { readHistoryFile } from './history-read';
import type { Bump } from './version-source';
export const FRAGMENT_README = 'README.md';
@@ -75,8 +76,8 @@ export function readFragments(cwd: string, treeish: string | null, dir: string,
return out;
};
const excluded = new Map((exclude ? list(exclude) : []).map(e => [e.path, e.sha]));
const shown = exclude && changelog ? git(cwd, ['show', `${exclude}:${changelog}`]) : null;
const released = shown?.ok ? parseChangelog(shown.out.replace(/\r\n/g, '\n')).sections.filter(s => !s.unreleased).map(s => s.lines) : [];
const shown = exclude && changelog ? readHistoryFile(cwd, exclude, changelog) : null;
const released = shown !== null ? parseChangelog(shown.replace(/\r\n/g, '\n')).sections.filter(s => !s.unreleased).map(s => s.lines) : [];
const isReleased = (body: string) => {
const want = body.split('\n');
return released.some(lines => lines.some((_, i) => want.every((w, k) => lines[i + k] === w)));
+14 -10
View File
@@ -2,12 +2,14 @@
* Read one file at a commit when the checkout may be a blobless clone
* (`filter: blob:none`), where git fetches an old file's contents on demand
* and that fetch can fail. The TypeScript twin of
* .github/scripts/history-read.sh: existence comes from the ref's tree
* (`git ls-tree`, local data only); a listed path is read with `git show`,
* tried 3 times in a partial clone (only a fetch can recover) and once
* otherwise, waiting HISTORY_READ_BACKOFF_SECONDS (default 2) times the attempt
* between tries; every other failure throws HISTORY_READ_FAILED instead of
* reading as absent (docs/troubleshooting.md#history-read-failed).
* .github/scripts/history-read.sh. A read that `git show` answers is the
* file. When it fails, existence comes from the ref's tree (`git ls-tree
* --full-tree`, local data only): a path the tree does not list is absent, and
* a listed one is retried, 3 tries in all in a partial clone (only a fetch can
* recover) and none otherwise, waiting HISTORY_READ_BACKOFF_SECONDS (default 2)
* times the attempt between tries. Every other failure throws
* HISTORY_READ_FAILED instead of reading as absent
* (docs/troubleshooting.md#history-read-failed).
*/
import { spawnSync } from 'node:child_process';
@@ -25,15 +27,17 @@ export function readHistoryFile(repoRoot: string, ref: string, file: string): st
return new HistoryReadError(`cannot read ${file} at ${ref}. ${cause} (${version}). Fix: see docs/troubleshooting.md#history-read-failed (HISTORY_READ_FAILED)`);
};
const firstLine = (r: ReturnType<typeof git>) => (r.stderr ?? '').split('\n')[0] || r.error?.message || `exit ${r.status}`;
let shown = git(['show', `${ref}:${file}`]);
if (shown.status === 0) return shown.stdout;
const listed = git(['ls-tree', '--full-tree', ref, '--', file]);
if (listed.status !== 0) throw fail(`git ls-tree failed: ${firstLine(listed)}`);
if (!listed.stdout.trim()) return null;
const promisors = (git(['config', '--get-regexp', '^(remote\\..+\\.promisor|extensions\\.partialclone)$']).stdout ?? '').split('\n');
const tries = promisors.some(line => line && !/ false$/i.test(line)) ? HISTORY_READ_TRIES : 1;
for (let attempt = 1; ; attempt++) {
const shown = git(['show', `${ref}:${file}`]);
if (shown.status === 0) return shown.stdout;
if (attempt === tries) throw fail(`git show failed ${tries === 1 ? 'once (not a partial clone, so no retry)' : `${tries} times`}: ${firstLine(shown)}`);
for (let attempt = 1; attempt < tries; attempt++) {
Bun.sleepSync(attempt * 1000 * Number(process.env.HISTORY_READ_BACKOFF_SECONDS ?? 2));
shown = git(['show', `${ref}:${file}`]);
if (shown.status === 0) return shown.stdout;
}
throw fail(`git show failed ${tries === 1 ? 'once (not a partial clone, so no retry)' : `${tries} times`}: ${firstLine(shown)}`);
}
+8
View File
@@ -16,6 +16,7 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { readFragments } from '../lib/changelog-fragments';
import { HistoryReadError, readHistoryFile } from '../lib/history-read';
import { classifyTrainHead } from '../lib/version-gate-train';
@@ -157,6 +158,13 @@ describe('history-read.sh and lib/history-read.ts on a blobless clone', () => {
absent.cut();
expect(classifyTrainHead({ repoRoot: absent.clone, baseRef: 'main', head: absent.one })).toEqual({ kind: 'off' });
});
test('the fragment reader fails loudly when the base CHANGELOG cannot be fetched, and treats an absent one as nothing released', () => {
const fx = fixture('fragments', { ...ONE, 'CHANGELOG.md': '# Changelog\n\n## [1.0.0.0]\n\n- one\n', 'changelog.d/pr.md': '- the PR entry\n' }, { 'CHANGELOG.md': '# Changelog\n\n## [1.0.0.1]\n\n- two\n\n## [1.0.0.0]\n\n- one\n' });
fx.cut();
expect(() => readFragments(fx.clone, fx.one, 'changelog.d', fx.two, 'CHANGELOG.md', 'skip')).toThrow(HistoryReadError);
expect(readFragments(fx.clone, fx.one, 'changelog.d', fx.two, 'NO-CHANGELOG.md', 'skip').errors).toEqual([]);
});
});
type Step = { name?: string; uses?: string; run?: string; with?: Record<string, unknown> };