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
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
# Every git source in the repository names an immutable commit or a tag.
|
||||
#
|
||||
# A source that follows a branch ("#branch=quattro", or no fragment at all)
|
||||
# produces a package whose contents depend on when it was built, and nothing
|
||||
# in this repository changes when that branch moves, so the CI publish path,
|
||||
# which builds what a merge touched, never rebuilds it. Packages that need to
|
||||
# follow a branch declare a git_branch upstream watch instead, and the tracker
|
||||
# turns each new tip into a commit pin here (docs/upstream-sources.md).
|
||||
set -euo pipefail
|
||||
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
||||
PKGBUILDS_DIR=${PKGBUILDS_DIR:-$BUILD_ROOT/pkgbuilds}
|
||||
|
||||
failures=0
|
||||
checked=0
|
||||
for pkgdir in "$PKGBUILDS_DIR"/*/; do
|
||||
[[ -f "$pkgdir/PKGBUILD" ]] || continue
|
||||
package=$(basename "$pkgdir")
|
||||
for arch in x86_64 aarch64; do
|
||||
# Sourced the way the build tooling reads recipes: CARCH set, the local
|
||||
# source override unset, so conditional and arch-suffixed arrays count.
|
||||
sources=$(cd "$pkgdir" && env -u OMARCHY_SRC CARCH="$arch" bash -c '
|
||||
source PKGBUILD >/dev/null 2>&1
|
||||
printf "%s\n" "${source[@]}" "${source_x86_64[@]}" "${source_aarch64[@]}"' 2>/dev/null) || {
|
||||
echo "FAIL: $package: PKGBUILD could not be sourced for $arch"
|
||||
failures=$((failures + 1))
|
||||
continue
|
||||
}
|
||||
while IFS= read -r entry; do
|
||||
[[ -n "$entry" ]] || continue
|
||||
url="${entry#*::}"
|
||||
[[ "$url" == git+* ]] || continue
|
||||
checked=$((checked + 1))
|
||||
case "$url" in
|
||||
*'#commit='*|*'#tag='*) ;;
|
||||
*)
|
||||
echo "FAIL: $package ($arch): git source is not pinned to a commit or tag: $url"
|
||||
failures=$((failures + 1))
|
||||
;;
|
||||
esac
|
||||
done <<<"$sources"
|
||||
done
|
||||
done
|
||||
|
||||
if ((failures)); then
|
||||
echo "$failures unpinned git source(s). Pin with #commit= (and a git_branch upstream watch to move the pin), or #tag= with a checksum."
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: $checked git source(s) across pkgbuilds/ are pinned to a commit or tag"
|
||||
@@ -178,6 +178,85 @@ b2sums=('old' 'local-b2')
|
||||
expected = subprocess.check_output(['git', '-c', 'core.abbrev=no', '-C', str(repo), 'archive', '--format', 'tar', 'v1.0'])
|
||||
self.assertEqual(archive.read_bytes(), expected)
|
||||
|
||||
def branch_fixture(self, fresh_tip=False):
|
||||
"""An upstream with two release tags and commits past the newest one,
|
||||
all committed years ago; with fresh_tip, one more commit dated now."""
|
||||
repo = self.root / 'branch-upstream'
|
||||
repo.mkdir()
|
||||
git = ['git', '-C', str(repo), '-c', 'user.name=Test', '-c', 'user.email=test@example.test']
|
||||
old = {**os.environ, 'GIT_COMMITTER_DATE': '2020-01-01T00:00:00+00:00', 'GIT_AUTHOR_DATE': '2020-01-01T00:00:00+00:00'}
|
||||
subprocess.run(['git', 'init', '-q', '-b', 'main', str(repo)], check=True)
|
||||
shas = []
|
||||
def commit(index, env):
|
||||
(repo / 'source').write_text(f'revision {index}')
|
||||
subprocess.run([*git, 'add', '.'], check=True)
|
||||
subprocess.run([*git, 'commit', '-qm', f'commit {index}'], env=env, check=True)
|
||||
shas.append(subprocess.check_output([*git, 'rev-parse', 'HEAD'], text=True).strip())
|
||||
for index, tag in enumerate([None, 'v1.0.0', 'v1.1.0', None, None]):
|
||||
commit(index, old)
|
||||
if tag:
|
||||
subprocess.run([*git, 'tag', tag], check=True)
|
||||
# A newer release tagged on another branch is not something main is "past".
|
||||
subprocess.run([*git, 'checkout', '-q', '-b', 'hotfix', shas[1]], check=True)
|
||||
(repo / 'hotfix').write_text('x')
|
||||
subprocess.run([*git, 'add', '.'], check=True)
|
||||
subprocess.run([*git, 'commit', '-qm', 'hotfix'], env=old, check=True)
|
||||
subprocess.run([*git, 'tag', 'v9.9.9'], check=True)
|
||||
subprocess.run([*git, 'checkout', '-q', 'main'], check=True)
|
||||
if fresh_tip:
|
||||
commit(len(shas), os.environ)
|
||||
return repo, shas
|
||||
|
||||
def redirect_clone(self, repo):
|
||||
command = w.subprocess.run
|
||||
def redirect(args, **kwargs):
|
||||
if 'clone' in args:
|
||||
args = [f'file://{repo}' if arg == 'https://example.test/tool.git' else arg for arg in args]
|
||||
return command(args, **kwargs)
|
||||
return patch.object(w.subprocess, 'run', side_effect=redirect)
|
||||
|
||||
def test_git_branch_versions_from_reachable_tag_and_shares_one_clone(self):
|
||||
repo, shas = self.branch_fixture()
|
||||
with self.redirect_clone(repo):
|
||||
tip = w.git_branch_tip('https://example.test/tool.git', 'main', r'v(?P<version>[0-9.]+)', self.fetch.cache)
|
||||
again = w.git_branch_tip('https://example.test/tool.git', 'main', None, self.fetch.cache)
|
||||
self.assertEqual((tip['commit'], tip['tag'], tip['version'], tip['distance'], tip['count']), (shas[-1], 'v1.1.0', '1.1.0', '2', '5'))
|
||||
self.assertEqual(again['commit'], shas[-1])
|
||||
self.assertEqual(len(list(self.fetch.cache.glob('*.branch.git'))), 1, 'one clone per branch per run')
|
||||
watch = {'git_branch': 'https://example.test/tool.git', 'branch': 'main', 'tag_pattern': r'v(?P<version>[0-9.]+)',
|
||||
'version': '{version}.r{distance}.g{commit:.7}', 'variables': {'_commit': '{commit}'}}
|
||||
pkgver = w.candidate(watch, tip)['pkgver']
|
||||
self.assertEqual(pkgver, f'1.1.0.r2.g{shas[-1][:7]}')
|
||||
self.assertEqual(w.vercmp(pkgver, '1.1.0'), 1)
|
||||
self.assertEqual(w.vercmp(pkgver, '1.1.1'), -1)
|
||||
with self.redirect_clone(repo), self.assertRaisesRegex(ValueError, 'no tag'):
|
||||
w.git_branch_tip('https://example.test/tool.git', 'main', r'release-(?P<version>[0-9.]+)', self.root / 'other-cache')
|
||||
|
||||
def test_git_branch_min_age_selects_the_newest_settled_commit(self):
|
||||
repo, shas = self.branch_fixture(fresh_tip=True)
|
||||
with self.redirect_clone(repo):
|
||||
tip = w.git_branch_tip('https://example.test/tool.git', 'main', None, self.fetch.cache)
|
||||
settled = w.git_branch_tip('https://example.test/tool.git', 'main', r'v(?P<version>[0-9.]+)', self.fetch.cache, min_age=3600)
|
||||
nothing = w.git_branch_tip('https://example.test/tool.git', 'main', None, self.fetch.cache, min_age=10 ** 9)
|
||||
self.assertEqual(tip['commit'], shas[-1], 'no window: the fresh tip')
|
||||
self.assertEqual((settled['commit'], settled['distance'], settled['count']), (shas[-2], '2', '5'), 'one hour window: the commit before it')
|
||||
self.assertIsNone(nothing, 'a window older than every commit selects nothing')
|
||||
watch = {'git_branch': 'https://example.test/tool.git', 'branch': 'main'}
|
||||
with self.redirect_clone(repo):
|
||||
self.assertEqual(w.discover(watch, self.fetch, min_age=10 ** 9), [])
|
||||
self.assertEqual(w.discover(watch, self.fetch, min_age=3600)[0]['values']['commit'], shas[-2])
|
||||
|
||||
def test_git_branch_tag_template_requires_tag_pattern(self):
|
||||
base = {'git_branch': 'https://example.test/tool.git', 'branch': 'main'}
|
||||
w.validate({**base, 'version': '{date}.r{count}'})
|
||||
w.validate({**base, 'tag_pattern': r'v(?P<version>[0-9.]+)', 'version': '{version}.r{distance}.g{commit:.7}'})
|
||||
for extra in [{'version': '{version}.r{distance}'}, {'version': '{tag}'}, {'tag_pattern': 'v[0-9.]+'},
|
||||
{'tag_pattern': 7}, {'tag_pattern': ''}]:
|
||||
with self.subTest(extra=extra), self.assertRaises(ValueError):
|
||||
w.validate({**base, **extra})
|
||||
with self.assertRaisesRegex(ValueError, 'only applies'):
|
||||
w.validate({'github': 'owner/tool', 'pattern': r'v(?P<version>[0-9.]+)', 'tag_pattern': r'v(?P<version>[0-9.]+)'})
|
||||
|
||||
def test_invalid_optional_metadata_fails_validation(self):
|
||||
valid = {'github': 'owner/tool', 'pattern': r'v(?P<version>[0-9.]+)'}
|
||||
for extra in [{'variables': []}, {'fields': {'commit': 3}}, {'submodules': {'pkgver': 'libs/common'}},
|
||||
|
||||
Reference in new issue
Block a user