new file mode 100644
@@ -0,0 +1,102 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# vim: set ts=4 sw=4 et:
+#
+# Copyright (c) 2026 Ericsson AB
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# AUTHORS
+# Daniel Turull <daniel.turull@ericsson.com>
+#
+
+import json
+import os
+import time
+
+from logging import warning as W
+
+RESULT_SUCCESS = "success"
+RESULT_FAILURE = "failure"
+
+STATE_FILENAME = "auh-state.json"
+STATE_VERSION = 1
+
+SECONDS_PER_DAY = 86400
+DEFAULT_COOLDOWN_DAYS = 30
+DEFAULT_SUCCESS_MAX_AGE_DAYS = 30
+
+
+class State:
+ """Tracks upgrade attempts as {recipe: {version: {timestamp, result}}}."""
+
+ def __init__(self, state_dir, cooldown_days=DEFAULT_COOLDOWN_DAYS,
+ success_max_age_days=DEFAULT_SUCCESS_MAX_AGE_DAYS):
+ self.path = os.path.join(state_dir, STATE_FILENAME)
+ self.cooldown = cooldown_days * SECONDS_PER_DAY
+ self.success_max_age = success_max_age_days * SECONDS_PER_DAY
+ self.data = self._load()
+ self._prune()
+
+ def _load(self):
+ if os.path.exists(self.path):
+ try:
+ with open(self.path) as f:
+ raw = json.load(f)
+ except (json.JSONDecodeError, OSError) as e:
+ W(" %s is corrupt (%s), starting fresh" % (self.path, e))
+ return {}
+ if not isinstance(raw, dict) or raw.get("version") != STATE_VERSION:
+ W(" %s: unsupported or missing version, starting fresh"
+ % self.path)
+ return {}
+ return raw.get("recipes", {})
+ return {}
+
+ def save(self):
+ with open(self.path, "w") as f:
+ json.dump({"version": STATE_VERSION, "recipes": self.data},
+ f, indent=2)
+
+ def record(self, pn, version, result):
+ entry = {"timestamp": time.time(), "result": result}
+ if pn not in self.data:
+ self.data[pn] = {}
+ self.data[pn][version] = entry
+
+ def should_skip(self, pn, version):
+ entry = self.data.get(pn, {}).get(version)
+ if not entry:
+ return False
+ age = time.time() - entry.get("timestamp", 0)
+ if entry.get("result") == RESULT_SUCCESS:
+ return age < self.success_max_age
+ return age < self.cooldown
+
+ def _prune(self):
+ """Remove stale entries older than their respective max-age."""
+ now = time.time()
+ for pn in list(self.data):
+ versions = self.data[pn]
+ for ver in list(versions):
+ entry = versions[ver]
+ age = now - entry.get("timestamp", 0)
+ if entry.get("result") == RESULT_SUCCESS:
+ if age > self.success_max_age:
+ del versions[ver]
+ else:
+ if age > self.cooldown:
+ del versions[ver]
+ if not versions:
+ del self.data[pn]
@@ -50,7 +50,15 @@
# passed; does not apply when layer_mode is enabled).
#blacklist=python glibc gcc
-# specify the directory where work (patches) will be saved
+# When running with --incremental, how many days to wait before retrying
+# a failed upgrade attempt for the same recipe version. Default is 30 days.
+#retry_interval=30
+
+# When running with --incremental, how many days to skip a successfully
+# upgraded recipe version before attempting it again. Default is 30 days.
+#success_max_age=30
+
+# specify the directory where work (patches) will be saved
# (optional; default is BUILDDIR/upgrade-helper/)
#workdir=
@@ -59,6 +59,8 @@ from utils.emailhandler import Email
from statistics import Statistics
from steps import upgrade_steps
from testimage import TestImage
+from state import (State, RESULT_SUCCESS, RESULT_FAILURE,
+ DEFAULT_COOLDOWN_DAYS, DEFAULT_SUCCESS_MAX_AGE_DAYS)
if not os.getenv('BUILDDIR', False):
E(" You must source oe-init-build-env before running this script!\n")
@@ -104,6 +106,8 @@ def parse_cmdline():
help="do not compile, just change the checksums, remove PR, and commit")
parser.add_argument("-c", "--config-file", default=None,
help="Path to the configuration file. Default is $BUILDDIR/upgrade-helper/upgrade-helper.conf")
+ parser.add_argument("--incremental", action="store_true",
+ help="skip recipes already attempted (uses JSON state file)")
parser.add_argument("--layer-names", nargs='*', action="store", default='',
help="layers to include in the upgrade research")
parser.add_argument("--layer-dir", action="store", default='',
@@ -169,6 +173,16 @@ class Updater(object):
self.email_handler = Email(settings)
self.statistics = Statistics()
+ if self.args.incremental:
+ cooldown = int(settings.get('retry_interval', DEFAULT_COOLDOWN_DAYS))
+ success_max_age = int(settings.get('success_max_age',
+ DEFAULT_SUCCESS_MAX_AGE_DAYS))
+ self.state = State(self.uh_dir,
+ cooldown_days=cooldown,
+ success_max_age_days=success_max_age)
+ else:
+ self.state = None
+
def _set_options(self):
self.opts = {}
self.opts['layer_mode'] = settings.get('layer_mode', '')
@@ -468,6 +482,14 @@ class Updater(object):
pkggroups_ctx.append({"name":",".join([pkg_ctx['PN'] for pkg_ctx in pkgs_ctx]),"pkgs":pkgs_ctx,"error":None, 'base_dir':self.uh_recipes_all_dir})
I(" ############################################################")
+ if self.state:
+ before = len(pkggroups_ctx)
+ pkggroups_ctx = [g for g in pkggroups_ctx
+ if not self.state.should_skip(
+ g['pkgs'][0]['PN'], g['pkgs'][0]['NPV'])]
+ I(" %d/%d package groups skipped (incremental mode)"
+ % (before - len(pkggroups_ctx), before))
+ total_pkggroups = len(pkggroups_ctx)
if pkggroups_ctx and not self.args.skip_compilation:
I(" Building gcc runtimes ...")
for machine in self.opts['machines']:
@@ -520,6 +542,10 @@ class Updater(object):
g['error'] = e
failed_pkggroups_ctx.append(g)
+ # Capture upgrade error before commit_changes() which may
+ # overwrite g['error'] with a git commit failure.
+ upgrade_err = g.get('error') if self.state else None
+
try:
self.commit_changes(g)
except Exception as e:
@@ -534,6 +560,21 @@ class Updater(object):
succeeded_pkggroups_ctx.remove(g)
failed_pkggroups_ctx.append(g)
+ if self.state:
+ # UpgradeNotNeededError means the recipe is already current,
+ # which is a successful outcome — no retry needed.
+ if not upgrade_err or isinstance(upgrade_err, UpgradeNotNeededError):
+ result = RESULT_SUCCESS
+ else:
+ result = RESULT_FAILURE
+ # All packages in a group share the same result because AUH
+ # upgrades them atomically; individual outcomes are not tracked.
+ for pkg_ctx in g['pkgs']:
+ self.state.record(pkg_ctx['PN'], pkg_ctx['NPV'], result)
+
+ if self.state:
+ self.state.save()
+
if self.opts['testimage']:
ctxs = {}
ctxs['succeeded'] = succeeded_pkggroups_ctx