ci/cd: fix tup cache rebuild (#525)
Build system / Build (es_ES) (push) Successful in 10m27s
Build system / Build (en_US) (push) Successful in 10m33s
Build system / Build (ru_RU) (push) Successful in 10m32s
Build system / Publish Images (push) Successful in 2m7s

Problem: `sync-tup-ctime.py` realigned every node's ctime, which hid real source changes whose Tupfile didn't change.
Fix: stamp the cache with its commit and on restore, `git diff` against it, keeping changed files dirty so tup rebuilds them.

Reviewed-on: #525
Reviewed-by: Mikhail Frolov <mixa.frolov2003@gmail.com>
Reviewed-by: Gleb Zaharov <risdeveau@lair.moe>
This commit was merged in pull request #525.
This commit is contained in:
2026-06-26 06:15:52 +00:00
parent 8c45972a96
commit 481b5779b8
3 changed files with 72 additions and 16 deletions
+44 -8
View File
@@ -2,10 +2,13 @@
# Sync tup's mtime/mtime_ns in .tup/db with the actual filesystem ctime.
#
# On Linux, tup uses st_ctim (ctime) for change detection, not st_mtim.
# After restoring build cache with cp -a, files get new ctimes while the
# After restoring a build cache with cp -a, files get new ctimes while the
# database still has old values. This script updates the database to match.
#
# Usage: python3 sync-tup-ctime.py [path/to/.tup/db]
# Files in changed-files-list (changed since the cache's commit) are forced
# dirty instead, so tup still rebuilds them and their dependents.
#
# Usage: python3 sync-tup-ctime.py [path/to/.tup/db] [changed-files-list]
import os
import sqlite3
@@ -15,6 +18,15 @@ db_path = sys.argv[1] if len(sys.argv) > 1 else ".tup/db"
if not os.path.isfile(db_path):
sys.exit(f"Database not found: {db_path}")
# Paths changed since the cache's commit: keep dirty so tup rebuilds them.
dirty = set()
if len(sys.argv) > 2 and sys.argv[2]:
if os.path.isfile(sys.argv[2]):
with open(sys.argv[2], encoding="utf-8") as f:
dirty = {line.strip() for line in f if line.strip()}
else:
print(f"Warning: changed-files list not found: {sys.argv[2]}")
db = sqlite3.connect(db_path)
# Map every node id -> (parent dir id, name). Any node type can be a parent
# directory in the chain (generated/variant dirs are not type 0/2), so the map
@@ -25,7 +37,8 @@ nodes = {
"select id, dir, name from node"
)
}
paths, updated, skipped = {}, 0, []
paths, updated, forced, skipped = {}, 0, 0, []
matched = set() # dirty paths that actually resolved to a tup node
for node_id, dir_id, name, ntype, old_sec, old_ns in db.execute(
"select id, dir, name, type, mtime, mtime_ns from node where type in (0, 2, 4)"
@@ -37,7 +50,23 @@ for node_id, dir_id, name, ntype, old_sec, old_ns in db.execute(
cur, part = nodes[cur]
parts.append(part)
paths[dir_id] = "/".join(reversed(parts))
path = os.path.join(paths[dir_id], name) if paths[dir_id] else name
# Build the path with forward slashes (tup stores them that way and git
# emits them that way), independent of the host os.sep. tup's root dir
# node is named ".", so paths come out as "./foo/bar"; strip that prefix
# so they match git's plain "foo/bar" entries in the dirty set.
path = paths[dir_id] + "/" + name if paths[dir_id] else name
if path.startswith("./"):
path = path[2:]
# Changed since the cache base: force a rebuild instead of aligning.
if path in dirty:
matched.add(path)
if old_sec != 0 or old_ns != 0:
db.execute(
"update node set mtime=0, mtime_ns=0 where id=?", (node_id,))
forced += 1
continue
try:
stat = os.stat(path)
except OSError:
@@ -53,13 +82,20 @@ for node_id, dir_id, name, ntype, old_sec, old_ns in db.execute(
db.commit()
db.close()
print(f"Updated ctime for {updated} nodes, skipped {len(skipped)} missing files")
print(f"Aligned {updated} nodes, forced {forced} changed nodes dirty "
f"({len(matched)}/{len(dirty)} changed paths matched a tup node), "
f"skipped {len(skipped)} missing files")
# A changed path that matches no tup node is normal (docs, CI files, etc.),
# but if NONE of them match while the list is non-empty, the path format from
# git almost certainly disagrees with tup's node paths -- which means changed
# files would silently NOT be rebuilt. Make that loud instead of silent.
if dirty and not matched:
print("WARNING: no changed path matched a tup node -- path format "
"mismatch likely; changed files may NOT be rebuilt.")
if skipped:
from collections import Counter
by_type = Counter(ntype for _, ntype in skipped)
print("Skipped by node type: "
+ ", ".join(f"type {t}: {c}" for t, c in sorted(by_type.items())))
print("Skipped (missing) files:")
for path, ntype in sorted(skipped):
print(f" [type {ntype}] {path}")
+16 -4
View File
@@ -127,12 +127,20 @@ jobs:
echo "cache_restored=true" >> $GITEA_OUTPUT
fi
# cache restore resets file ctimes
# tup keys change detection on ctime
# so realign its DB to avoid rebuilding everything
# cache restore resets ctimes; realign tup DB to skip a full rebuild,
# but keep files changed since the cache's commit dirty so tup rebuilds them
- name: Sync tup DB with filesystem ctime
if: steps.check-tup-cache.outputs.cache_restored == 'true'
run: python3 .gitea/utils/sync-tup-ctime.py
run: |
DIRTY=$(mktemp)
BASE=$(cat .tup/build-commit 2>/dev/null || true)
if [ -n "$BASE" ] && git cat-file -e "${BASE}^{commit}" 2>/dev/null; then
git -c core.quotePath=false diff --name-only "$BASE" HEAD > "$DIRTY"
echo "Cache built at $BASE; $(wc -l < "$DIRTY") changed files kept dirty"
python3 .gitea/utils/sync-tup-ctime.py .tup/db "$DIRTY"
else
echo "No usable cache base commit; skipping sync (tup rebuilds all)"
fi
# write the variant config
- name: Configure tup
@@ -161,6 +169,10 @@ jobs:
source kos32-export-env-vars ${{ gitea.workspace }}
tup build-${{ matrix.lang }}
# stamp the cache with its commit so later builds diff against it
- name: Record cache base commit
run: echo "${{ gitea.sha }}" > .tup/build-commit
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ARTIFACTS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+12 -4
View File
@@ -129,12 +129,20 @@ jobs:
echo "cache_restored=true" >> $GITEA_OUTPUT
fi
# cache restore resets file ctimes
# tup keys change detection on ctime
# so realign its DB to avoid rebuilding everything
# cache restore resets ctimes; realign tup DB to skip a full rebuild,
# but keep files changed since the cache's commit dirty so tup rebuilds them
- name: Sync tup DB with filesystem ctime
if: steps.check-tup-cache.outputs.cache_restored == 'true'
run: python3 .gitea/utils/sync-tup-ctime.py
run: |
DIRTY=$(mktemp)
BASE=$(cat .tup/build-commit 2>/dev/null || true)
if [ -n "$BASE" ] && (git cat-file -e "${BASE}^{commit}" 2>/dev/null || git fetch --depth=1 origin "$BASE" 2>/dev/null); then
git -c core.quotePath=false diff --name-only "$BASE" HEAD > "$DIRTY"
echo "Cache built at $BASE; $(wc -l < "$DIRTY") changed files kept dirty"
python3 .gitea/utils/sync-tup-ctime.py .tup/db "$DIRTY"
else
echo "No usable cache base commit; skipping sync (tup rebuilds all)"
fi
# write the variant config
- name: Configure tup