Track upstream branches as pinned releases on an unattended lane
Since publishing moved to CI on merge, a package whose PKGBUILD never
changes while its source moves was never rebuilt: omarchy-dev and
omarchy-settings-dev followed quattro through "#branch=" and a pkgver()
function, and nothing in this repository changed when quattro did. The
host timers that used to notice are off, so edge fell days behind.
The rule now: no git source without a commit or tag pin
(tests/pinned-sources.sh, run in CI). A package that has to follow a
branch declares a git_branch upstream watch, and the pin moves through
the same PR/build/publish path as every other version bump.
Watch (helpers/upstream-watch.py)
git_branch gains tag_pattern: the newest release tag in the pinned
commit's own history, exposed as {tag}/{version}/{distance}, so a
branch build is versioned <tag>.r<n>.g<sha>, above the release it
follows and below the next one. One blobless clone per branch per
run, shared by every package on it. min_release_age selects the
newest commit older than the window, so a push burst builds once.
Lane (helpers/package-metadata.sh, bin/sync-upstream --lane)
"auto_merge": true moves a package from the reviewed 6-hourly sync
PR to the unattended lane. Packages pinned from the same branch move
together: a failure on one restores the others and fails the group,
so the dev pair can never ship from two quattro commits.
Tracker (.github/workflows/track-branches.yml)
Every two hours: pin, open one PR with a GitHub App token, enable
auto-merge. Branch protection still gates the merge on result,
self-tests and build-isolation. A tip that fails to build stays an
open red PR until the next tick supersedes it. The App is required:
a PR opened with GITHUB_TOKEN has its checks held for approval and
its auto-merge would not fire publish.yml.
The reviewed workflows (sync-upstream, sync-rebuilds) open their PRs
with the same App so their builds start without a maintainer clicking
"Approve workflows to run"; without the App they fall back to
GITHUB_TOKEN and behave as before.
Recipes
The dev pair pins _commit and a real sha256sum, keeps the OMARCHY_SRC
override, and drops pkgver(). Its r-number stays the branch's total
commit count because the published history used it and pacman must
never see the version go down. omasnap-git is new: omacom/omasnap
main, versioned <tag>.r<distance>.g<sha>, provides/conflicts omasnap.
This commit is contained in:
1 parent
e7da505280
commit
d87686ca4f
17 files changed
+746
-68
No files matched your search
@@ -13,6 +13,7 @@
|
||||
# { "source": "local", "channels": ["edge"] }
|
||||
# { "source": "local", "channels": ["edge", "rc", "stable"] }
|
||||
# { "source": "local", "min_release_age": "24h" }
|
||||
# { "source": "local", "auto_merge": true, "upstream": { "watch": { "git_branch": "...", "branch": "main" } } }
|
||||
# { "source": "local", "upstream": { "github": "owner/repo", "checksums": "SHASUMS256.txt", "assets": { "x86_64": ["name-{tag}-x64.tar.xz"] } } }
|
||||
# { "source": "local", "upstream": { "github": "owner/repo", "digests": true, "assets": { "x86_64": "name-{tag}-x64.tar.xz" } } }
|
||||
# { "source": "local", "upstream": { "git_tags": "https://example/repo.git", "tag_pattern": "v{pkgver}", "sources": { "any": ["https://example/archive/{tag}.tar.gz"] } } }
|
||||
@@ -327,6 +328,31 @@ packages_for_upstream_sync() {
|
||||
done
|
||||
}
|
||||
|
||||
# Upstream updates travel in one of two lanes. The reviewed lane is the
|
||||
# 6-hourly sync PR a maintainer reads before merging. A package that marks
|
||||
# "auto_merge": true rides the unattended lane instead: its bump PR is opened
|
||||
# and auto-merged by the branch tracker as soon as CI is green, which is how a
|
||||
# package that follows a moving branch (omarchy-dev, omasnap-git) gets rebuilt
|
||||
# without anyone clicking. The lanes are disjoint so a branch tip can never
|
||||
# hold up a reviewed vendor release, or the other way round.
|
||||
package_auto_merge() {
|
||||
local pkgdir="$1" metadata
|
||||
metadata=$(metadata_file_for_dir "$pkgdir")
|
||||
[[ -f "$metadata" ]] || return 1
|
||||
[[ "$(jq -r 'if has("auto_merge") then .auto_merge else false end' "$metadata")" == "true" ]]
|
||||
}
|
||||
|
||||
# package_in_lane <pkgdir> <reviewed|auto-merge|all>
|
||||
package_in_lane() {
|
||||
local pkgdir="$1" lane="$2"
|
||||
case "$lane" in
|
||||
all | "") return 0 ;;
|
||||
auto-merge) package_auto_merge "$pkgdir" ;;
|
||||
reviewed) ! package_auto_merge "$pkgdir" ;;
|
||||
*) echo "invalid lane: $lane (expected reviewed, auto-merge, or all)" >&2; return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Packages that must be rebuilt when a dependency they link against changes,
|
||||
# even though nothing in their own source moved. `rebuild_on` names those
|
||||
# dependencies; `rebuilt_against` records the versions the checked-in pkgrel was
|
||||
@@ -514,6 +540,15 @@ validate_package_metadata() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! jq -e 'if has("auto_merge") | not then true else (.auto_merge | type) == "boolean" end' "$metadata" >/dev/null; then
|
||||
echo "invalid auto_merge for $(basename "$pkgdir"): must be boolean"
|
||||
return 1
|
||||
fi
|
||||
if package_auto_merge "$pkgdir" && ! package_has_upstream_provider "$pkgdir" && ! package_has_upstream_hook "$pkgdir"; then
|
||||
echo "invalid auto_merge for $(basename "$pkgdir"): only an upstream watch, provider, or hook can be auto-merged"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# `has` rather than `// {}`: jq's // treats false as absent, which would
|
||||
# let "upstream": false slip through as an empty declaration.
|
||||
if ! jq -e '
|
||||
|
||||
+74
-10
@@ -82,7 +82,7 @@ def validate(watch):
|
||||
allowed = PROVIDERS | {"pattern", "path", "package", "branch", "variables", "fields",
|
||||
"submodules", "allow_prerelease", "unescape_json", "filenames",
|
||||
"sequence", "version", "revision", "revision_variable",
|
||||
"mutable_sources", "member", "dist_tag"}
|
||||
"mutable_sources", "member", "dist_tag", "tag_pattern"}
|
||||
if watch.keys() - allowed:
|
||||
raise ValueError(f"unknown watch fields: {sorted(watch.keys() - allowed)}")
|
||||
value = watch[provider]
|
||||
@@ -113,6 +113,18 @@ def validate(watch):
|
||||
if not isinstance(branch, str) or not branch or branch.startswith("-"):
|
||||
raise ValueError("git branch watch needs an explicit branch")
|
||||
run(["git", "check-ref-format", "refs/heads/" + branch])
|
||||
# A branch watch whose version template names a tag needs to know
|
||||
# which tags count as releases; anything else is an untagged branch.
|
||||
if "tag_pattern" in watch:
|
||||
if not isinstance(watch["tag_pattern"], str) or not watch["tag_pattern"]:
|
||||
raise ValueError("watch.tag_pattern must be a regular expression string")
|
||||
if "version" not in re.compile(watch["tag_pattern"]).groupindex:
|
||||
raise ValueError("tag_pattern needs a named version group")
|
||||
template = watch.get("version", "{version}")
|
||||
if any(field in template for field in ("{tag", "{distance")) and "tag_pattern" not in watch:
|
||||
raise ValueError("a version built from {tag}/{distance} needs a tag_pattern")
|
||||
elif "tag_pattern" in watch:
|
||||
raise ValueError("tag_pattern only applies to git_branch watches")
|
||||
for field in ("variables", "submodules", "fields"):
|
||||
mapping = watch.get(field, {})
|
||||
if not isinstance(mapping, dict):
|
||||
@@ -172,7 +184,61 @@ def matches(watch, text, extra=None, full=False):
|
||||
yield candidate(watch, {**(extra or {}), **match.groupdict()})
|
||||
|
||||
|
||||
def discover(watch, fetch):
|
||||
def git_branch_tip(url, branch, tag_pattern, cache, min_age=0, now=None):
|
||||
"""Describe the newest commit on an upstream branch that has sat there for
|
||||
at least min_age seconds (the branch analogue of "the newest release older
|
||||
than the window ships"): commit, total count, date, and with a tag_pattern
|
||||
the newest release tag reachable from it plus the distance from that tag,
|
||||
so a branch build can be versioned <tag>.r<n>.g<sha>, above the release it
|
||||
follows and below the next one, the way a pkgver() function would.
|
||||
|
||||
One blobless single-branch clone per (url, branch) per run, shared by
|
||||
every package that tracks it, so two recipes pinned from one clone always
|
||||
see the same commit. The clone is read with git only; nothing in it runs.
|
||||
Returns None when every commit is younger than the window.
|
||||
"""
|
||||
https(url)
|
||||
key = hashlib.sha256(f"{url}#{branch}".encode()).hexdigest()
|
||||
work = Path(cache) / f"{key}.branch.git"
|
||||
if not work.exists():
|
||||
scratch = work.with_name(f"{work.name}.{os.getpid()}.tmp")
|
||||
subprocess.run(["git", "clone", "--quiet", "--bare", "--filter=blob:none", "--single-branch", "--branch", branch, url, str(scratch)], check=True)
|
||||
scratch.replace(work)
|
||||
git = ["git", "-C", str(work)]
|
||||
selector = ["HEAD"]
|
||||
if min_age:
|
||||
cutoff = (now or dt.datetime.now(dt.timezone.utc)) - dt.timedelta(seconds=min_age)
|
||||
selector = ["-1", f"--before={cutoff.isoformat()}", "HEAD"]
|
||||
commit = run([*git, "rev-list", *selector], text=True).split()[:1]
|
||||
if not commit:
|
||||
return None
|
||||
commit = commit[0]
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
raise ValueError("branch tip is not a commit")
|
||||
count = run([*git, "rev-list", "--count", commit], text=True).strip()
|
||||
date = run([*git, "show", "-s", "--format=%cs", commit], text=True).strip().replace("-", "")
|
||||
timestamp = run([*git, "show", "-s", "--format=%cI", commit], text=True).strip()
|
||||
values = {"version": date, "date": date, "count": count, "commit": commit, "published_at": timestamp}
|
||||
if tag_pattern:
|
||||
pattern = re.compile(tag_pattern)
|
||||
best = None
|
||||
# Only tags in this commit's history count; a release cut on another
|
||||
# branch is not something this branch is "past".
|
||||
for tag in run([*git, "tag", "--merged", commit], text=True).split():
|
||||
match = pattern.fullmatch(tag)
|
||||
if not match:
|
||||
continue
|
||||
version = match.group("version")
|
||||
if best is None or vercmp(version, best[0]) > 0:
|
||||
best = (version, tag)
|
||||
if best is None:
|
||||
raise ValueError(f"no tag on {branch} matches {tag_pattern}")
|
||||
distance = run([*git, "rev-list", "--count", f"{best[1]}..{commit}"], text=True).strip()
|
||||
values.update({"tag": best[1], "version": best[0], "distance": distance})
|
||||
return values
|
||||
|
||||
|
||||
def discover(watch, fetch, min_age=0):
|
||||
provider = validate(watch)
|
||||
feed = watch[provider]
|
||||
results = []
|
||||
@@ -199,13 +265,10 @@ def discover(watch, fetch):
|
||||
for tag, commit in tags.items():
|
||||
results.extend(matches(watch, tag, {"tag": tag, "commit": commit}, full=True))
|
||||
elif provider == "git_branch":
|
||||
with tempfile.TemporaryDirectory(prefix="upstream-git-") as work:
|
||||
subprocess.run(["git", "clone", "--quiet", "--bare", "--filter=blob:none", "--single-branch", "--branch", watch["branch"], feed, work], check=True)
|
||||
commit = run(["git", "-C", work, "rev-parse", "HEAD"], text=True).strip()
|
||||
count = run(["git", "-C", work, "rev-list", "--count", "HEAD"], text=True).strip()
|
||||
date = run(["git", "-C", work, "show", "-s", "--format=%cs", "HEAD"], text=True).strip().replace("-", "")
|
||||
timestamp = run(["git", "-C", work, "show", "-s", "--format=%cI", "HEAD"], text=True).strip()
|
||||
results.append(candidate(watch, {"version": date, "date": date, "count": count, "commit": commit, "published_at": timestamp}))
|
||||
tip = git_branch_tip(feed, watch["branch"], watch.get("tag_pattern"), fetch.cache, min_age)
|
||||
if tip is None:
|
||||
return [] # nothing has settled for min_age yet: wait, not an error
|
||||
results.append(candidate(watch, tip))
|
||||
elif provider == "npm":
|
||||
data = fetch.json("https://registry.npmjs.org/" + quote(feed, safe=""))
|
||||
version = data["dist-tags"][watch.get("dist_tag", "latest")]
|
||||
@@ -479,7 +542,8 @@ def sync(package, fetch, min_age=0, check=False):
|
||||
path = package / "PKGBUILD"
|
||||
original = path.read_text()
|
||||
before = read_recipe(path)
|
||||
release = select_release(discover(watch, fetch), min_age, bypass=os.environ.get("BYPASS_MIN_RELEASE_AGE") == "1")
|
||||
bypass = os.environ.get("BYPASS_MIN_RELEASE_AGE") == "1"
|
||||
release = select_release(discover(watch, fetch, 0 if bypass else min_age), min_age, bypass=bypass)
|
||||
if release is None:
|
||||
return {"status": "skipped", "reason": "minimum release age"}
|
||||
current = scalar(before, "pkgver")
|
||||
|
||||
Reference in new issue
Block a user