diff --git a/.github/scripts/history-read.sh b/.github/scripts/history-read.sh index 8f0e93d63..cb3c094d1 100755 --- a/.github/scripts/history-read.sh +++ b/.github/scripts/history-read.sh @@ -6,14 +6,14 @@ # # Usage: history-read.sh [--absent ] # -# 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 (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 (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")" diff --git a/lib/changelog-fragments.ts b/lib/changelog-fragments.ts index 3f5404cf8..3d21af17b 100644 --- a/lib/changelog-fragments.ts +++ b/lib/changelog-fragments.ts @@ -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))); diff --git a/lib/history-read.ts b/lib/history-read.ts index c0702512a..f1fb39f9f 100644 --- a/lib/history-read.ts +++ b/lib/history-read.ts @@ -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) => (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)}`); } diff --git a/test/ci-history-read.test.ts b/test/ci-history-read.test.ts index 8c4d064a7..b29465802 100644 --- a/test/ci-history-read.test.ts +++ b/test/ci-history-read.test.ts @@ -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 };