Skip to content

Commit eb6c72b

Browse files
committed
fix(index): replace the stanza for a re-published version instead of appending
A re-cut release reuses its version number, but the publish path is append-only: it downloads the channel's current Packages index, appends the stanza for the .deb it just fetched, and re-signs. For a re-cut that leaves TWO stanzas for the same name-version, both pointing at the same pool path: Package: wheels Version: 4.1.0 Filename: pool/stable/w/wheels/wheels_4.1.0_all.deb Size: 114764828 SHA256: 6bc9d8f8... Package: wheels Version: 4.1.0 Filename: pool/stable/w/wheels/wheels_4.1.0_all.deb Size: 114770228 SHA256: 6ddb239e... The pool object was overwritten by the re-cut build, so only the second stanza describes the file actually served. apt binds to the stale one and refuses the download. Verified against the live repo with a real client: E: Failed to fetch https://apt.wheels.dev/pool/stable/w/wheels/wheels_4.1.0_all.deb File has unexpected size (114770228 != 114764828). Mirror sync in progress? so `apt install wheels` fails outright on the stable channel until the index is wrong-then-right by luck of ordering. Changes: * scripts/replace-package-stanza.py - rewrites a Packages index so exactly one stanza exists per name-version. Stanzas for other versions are preserved byte-for-byte, including field order. * regenerate-apt-metadata.sh - emit the new stanza to a scratch file and replace rather than append, so a re-cut supersedes the previous record. The name/version used for matching come from the .deb's own control file, so they agree with what apt-ftparchive emits. Verified on the live stable index (2 x 4.1.0 stanzas -> 1, other 6 versions untouched, remaining stanza matches the served .deb's size and sha256). Signed-off-by: Peter Amiri <peter@alurium.com>
1 parent c60fc49 commit eb6c72b

3 files changed

Lines changed: 134 additions & 4 deletions

File tree

4.84 KB
Binary file not shown.

‎scripts/regenerate-apt-metadata.sh‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ if [ ! -f "$APT_CONF_TEMPLATE" ]; then
4343
exit 1
4444
fi
4545

46+
# Scratch file for the stanza apt-ftparchive emits for the .deb being published.
47+
TMP_STANZA=$(mktemp)
48+
trap 'rm -f "$TMP_STANZA"' EXIT
49+
4650
for DIST in $DISTRIBUTIONS; do
4751
echo "── Incrementally updating dists/${DIST}/ ──"
4852
DIST_DIR="dists/${DIST}"
@@ -57,6 +61,17 @@ for DIST in $DISTRIBUTIONS; do
5761
fi
5862
echo " new package: ${NEW_DEB}"
5963

64+
# The name/version this .deb declares. Taken from the package's own control
65+
# file rather than the filename so it matches what apt-ftparchive emits (and
66+
# keeps matching if nfpm ever changes its filename convention).
67+
NEW_PKG=$(dpkg-deb -f "$NEW_DEB" Package)
68+
NEW_VER=$(dpkg-deb -f "$NEW_DEB" Version)
69+
if [ -z "$NEW_PKG" ] || [ -z "$NEW_VER" ]; then
70+
echo "::error::Could not read Package/Version from ${NEW_DEB}"
71+
exit 1
72+
fi
73+
echo " declared as: ${NEW_PKG} ${NEW_VER}"
74+
6075
for COMPONENT in $COMPONENTS; do
6176
for ARCH in $ARCHITECTURES; do
6277
BIN_DIR="${DIST_DIR}/${COMPONENT}/binary-${ARCH}"
@@ -65,10 +80,21 @@ for DIST in $DISTRIBUTIONS; do
6580
# Existing index was downloaded by the workflow; first publish starts empty.
6681
[ -f "${BIN_DIR}/Packages" ] || : > "${BIN_DIR}/Packages"
6782

68-
# Emit the new package's stanza and append. `--arch` matches the old full
69-
# scan: an `all` package is emitted for both arches, `amd64` only for
70-
# binary-amd64 (arm64 yields an empty append, which is a no-op).
71-
apt-ftparchive --arch "$ARCH" packages "$NEW_DEB" >> "${BIN_DIR}/Packages"
83+
# Emit the new package's stanza. `--arch` matches the old full scan: an
84+
# `all` package is emitted for both arches, `amd64` only for binary-amd64
85+
# (arm64 yields an empty stanza, which is a no-op).
86+
apt-ftparchive --arch "$ARCH" packages "$NEW_DEB" > "${TMP_STANZA}"
87+
88+
if [ -s "${TMP_STANZA}" ]; then
89+
# Replace rather than append. Appending is what produced two stanzas for
90+
# the same name-version when a version is re-cut with a different build;
91+
# both referenced the same pool path, but the stale one described the
92+
# previous artifact, so apt rejected the download with "File has
93+
# unexpected size". A no-op append for the arch that emits nothing keeps
94+
# the index unchanged.
95+
python3 "$(dirname "$0")/replace-package-stanza.py" \
96+
"${BIN_DIR}/Packages" "${TMP_STANZA}"
97+
fi
7298

7399
gzip -9 --keep --force "${BIN_DIR}/Packages"
74100
done

‎scripts/replace-package-stanza.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env python3
2+
"""Replace a package stanza in an apt Packages index, keeping the file valid.
3+
4+
Why this exists
5+
---------------
6+
The publish path is incremental: it downloads the channel's current Packages
7+
index, appends the stanza for the .deb it just downloaded, and re-signs. That
8+
append-only behaviour is wrong for a *re-cut*, where a version is republished
9+
with a different artifact. The channel ends up with TWO stanzas for the same
10+
name-version:
11+
12+
Package: wheels
13+
Version: 4.1.0
14+
Filename: pool/stable/w/wheels/wheels_4.1.0_all.deb
15+
Size: 114764828
16+
SHA256: 6bc9d8f8...
17+
18+
Package: wheels
19+
Version: 4.1.0
20+
Filename: pool/stable/w/wheels/wheels_4.1.0_all.deb
21+
Size: 114770228
22+
SHA256: 6ddb239e...
23+
24+
Both point at the same pool path, but the pool object was overwritten by the
25+
re-cut build, so only the second stanza describes the file actually served.
26+
apt binds to the stale one and refuses the download:
27+
28+
E: Failed to fetch .../wheels_4.1.0_all.deb
29+
File has unexpected size (114770228 != 114764828). Mirror sync in progress?
30+
31+
This rewrites the index so exactly one stanza exists per name-version: any prior
32+
stanza for that pair is dropped and the new one is appended. Stanzas for other
33+
versions are preserved byte-for-byte, including field order.
34+
35+
Usage:
36+
replace-package-stanza.py <Packages-file> <new-stanza-file>
37+
38+
The new stanza is read from <new-stanza-file> (the output of
39+
`apt-ftparchive --arch <arch> packages <deb>`); its Package/Version fields
40+
determine which existing stanzas are replaced.
41+
"""
42+
import sys
43+
44+
45+
def parse_stanzas(text):
46+
"""Split a Packages file into stanzas, preserving field order and text."""
47+
stanzas = []
48+
current = []
49+
for line in text.splitlines():
50+
if line.strip() == "":
51+
if current:
52+
stanzas.append(current)
53+
current = []
54+
else:
55+
current.append(line)
56+
if current:
57+
stanzas.append(current)
58+
return stanzas
59+
60+
61+
def field_of(stanza, name):
62+
prefix = name + ":"
63+
for line in stanza:
64+
if line.startswith(prefix):
65+
return line[len(prefix):].strip()
66+
return None
67+
68+
69+
def main():
70+
if len(sys.argv) != 3:
71+
sys.exit("usage: replace-package-stanza.py <Packages-file> <new-stanza-file>")
72+
73+
packages_path, stanza_path = sys.argv[1], sys.argv[2]
74+
new_stanza = [ln for ln in open(stanza_path, encoding="utf-8").read().splitlines() if ln.strip()]
75+
if not new_stanza:
76+
sys.exit("ERROR: new stanza is empty")
77+
78+
name = field_of(new_stanza, "Package")
79+
version = field_of(new_stanza, "Version")
80+
if not name or not version:
81+
sys.exit("ERROR: new stanza is missing Package or Version")
82+
83+
try:
84+
existing = open(packages_path, encoding="utf-8").read()
85+
except FileNotFoundError:
86+
existing = ""
87+
88+
kept, removed = [], 0
89+
for stanza in parse_stanzas(existing):
90+
if field_of(stanza, "Package") == name and field_of(stanza, "Version") == version:
91+
removed += 1
92+
continue
93+
kept.append(stanza)
94+
95+
# Rebuild with a single blank line between stanzas and a trailing newline.
96+
blocks = ["\n".join(s) for s in kept] + ["\n".join(new_stanza)]
97+
with open(packages_path, "w", encoding="utf-8") as handle:
98+
handle.write("\n\n".join(blocks) + "\n")
99+
100+
print(f" {name} {version}: replaced {removed} existing stanza(s), kept {len(kept)} other(s)")
101+
102+
103+
if __name__ == "__main__":
104+
main()

0 commit comments

Comments
 (0)