Merge pull request #572 from omacom/track-branches
Track upstream Git branches through automatic package PRs
This commit is contained in:
16 files changed
+797
-63
No files matched your search
@@ -47,11 +47,13 @@ jobs:
|
||||
useradd -m -u "$HOST_UID" -g "$HOST_GID" runner
|
||||
chown -R runner:runner /workspace/pkgbuilds
|
||||
|
||||
# The reviewed lane only: packages marked auto_merge ride
|
||||
# track-branches.yml, which merges without a human.
|
||||
if [[ -n "${PACKAGES:-}" ]]; then
|
||||
read -r -a package_args <<< "$PACKAGES"
|
||||
runuser -u runner -- ./bin/sync-upstream "${package_args[@]}"
|
||||
runuser -u runner -- ./bin/sync-upstream --lane reviewed "${package_args[@]}"
|
||||
else
|
||||
runuser -u runner -- ./bin/sync-upstream
|
||||
runuser -u runner -- ./bin/sync-upstream --lane reviewed
|
||||
fi
|
||||
'
|
||||
env:
|
||||
|
||||
@@ -58,6 +58,7 @@ jobs:
|
||||
python tests/neovim-clipboard-tmux.py
|
||||
./tests/partial-release.sh
|
||||
./tests/published-build-plan.sh
|
||||
./tests/pinned-sources.sh
|
||||
./tests/controller.sh
|
||||
./tests/artifact-helpers.sh
|
||||
./tests/limine-mkinitcpio-hook.sh
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
name: Track upstream branches
|
||||
|
||||
# The unattended lane. Packages marked "auto_merge": true follow a moving
|
||||
# upstream branch (omarchy-dev and omarchy-settings-dev on quattro, omasnap-git
|
||||
# on main) rather than tagged releases, so nothing in this repository changes
|
||||
# when their source does. This workflow makes each new branch tip a commit pin
|
||||
# in the recipe, which publish.yml then treats like any other version bump:
|
||||
# the PR builds on the droplets, auto-merge lands it when `result` is green,
|
||||
# and the merge publishes the artifacts. A tip that fails to build stays an
|
||||
# unmerged red PR that the next tick supersedes.
|
||||
#
|
||||
# The PR and auto-merge use the PAT in PKGS_BOT_TOKEN so they trigger the
|
||||
# build and publish workflows. The built-in GITHUB_TOKEN cannot drive this
|
||||
# unattended chain. The PAT needs Contents: write and Pull requests: write
|
||||
# on this repository, and its owner must be trusted by the build workflow.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 2 hours, off the hour to dodge the scheduling backlog at :00
|
||||
- cron: '35 */2 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
packages:
|
||||
description: 'Specific packages to track (space-separated, leave empty for every auto_merge package)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# One tracker at a time: two runs racing on auto/track-branches would each
|
||||
# force-push their own pin over the other's.
|
||||
concurrency:
|
||||
group: track-branches
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
track:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Require the tracking token
|
||||
env:
|
||||
PKGS_BOT_TOKEN: ${{ secrets.PKGS_BOT_TOKEN }}
|
||||
run: |
|
||||
if [[ -z "$PKGS_BOT_TOKEN" ]]; then
|
||||
echo "::error::Set PKGS_BOT_TOKEN to a PAT with Contents: write and Pull requests: write on this repository, owned by an account trusted to trigger builds."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Same container as the reviewed sync: vercmp decides whether a pin is
|
||||
# an upgrade with the comparator pacman uses on users' machines.
|
||||
- name: Pin tracked branches to their current tips
|
||||
id: sync
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e PACKAGES="$PACKAGES" \
|
||||
-e UPSTREAM_GITHUB_TOKEN="$UPSTREAM_GITHUB_TOKEN" \
|
||||
-e HOST_UID="$(id -u)" \
|
||||
-e HOST_GID="$(id -g)" \
|
||||
-v "$PWD/bin:/workspace/bin:ro" \
|
||||
-v "$PWD/helpers:/workspace/helpers:ro" \
|
||||
-v "$PWD/pkgbuilds:/workspace/pkgbuilds" \
|
||||
-w /workspace \
|
||||
archlinux:base-devel bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
pacman -Syu --noconfirm git jq python libarchive
|
||||
|
||||
groupadd -g "$HOST_GID" runner
|
||||
useradd -m -u "$HOST_UID" -g "$HOST_GID" runner
|
||||
chown -R runner:runner /workspace/pkgbuilds
|
||||
|
||||
if [[ -n "${PACKAGES:-}" ]]; then
|
||||
read -r -a package_args <<< "$PACKAGES"
|
||||
runuser -u runner -- ./bin/sync-upstream --lane auto-merge "${package_args[@]}"
|
||||
else
|
||||
runuser -u runner -- ./bin/sync-upstream --lane auto-merge
|
||||
fi
|
||||
'
|
||||
env:
|
||||
PACKAGES: ${{ github.event.inputs.packages }}
|
||||
UPSTREAM_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check for changes
|
||||
if: ${{ !cancelled() && steps.sync.outcome != 'skipped' }}
|
||||
id: changes
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
git status --porcelain
|
||||
{
|
||||
echo "### Pinned"
|
||||
git diff --unified=0 -- 'pkgbuilds/*/PKGBUILD' | grep -E '^\+(pkgver|_commit)=' | sed 's/^+/- /'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# The PR title names what moved, so the merged history reads like a
|
||||
# changelog: "Track upstream branches: omarchy-dev 4.0.0.r6520.g1a2b3c4, ...".
|
||||
- name: Describe the pins
|
||||
if: ${{ !cancelled() && steps.changes.outputs.has_changes == 'true' }}
|
||||
id: describe
|
||||
run: |
|
||||
title=$(git diff --unified=0 -- 'pkgbuilds/*/PKGBUILD' \
|
||||
| awk '/^\+\+\+ b\/pkgbuilds\//{split($2,p,"/"); pkg=p[3]} /^\+pkgver=/{sub(/^\+pkgver=/,""); printf "%s %s, ", pkg, $0}' \
|
||||
| sed 's/, $//')
|
||||
echo "title=Track upstream branches: ${title}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Open or update the tracking PR
|
||||
if: ${{ !cancelled() && steps.changes.outputs.has_changes == 'true' }}
|
||||
id: pr
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.PKGS_BOT_TOKEN }}
|
||||
commit-message: ${{ steps.describe.outputs.title }}
|
||||
title: ${{ steps.describe.outputs.title }}
|
||||
body: |
|
||||
Automated pin of packages that follow a moving upstream branch
|
||||
(`"auto_merge": true` in `.omarchy/package.json`). Each package's
|
||||
`_commit` now points at the branch tip. Fresh tips wait until
|
||||
their commit timestamp is at least `min_release_age` old.
|
||||
|
||||
This PR auto-merges once the build checks pass. A failing build
|
||||
leaves it open; the next tracker run replaces it with the newer tip.
|
||||
branch: auto/track-branches
|
||||
delete-branch: true
|
||||
labels: automated
|
||||
|
||||
# Auto-merge, not a direct merge: branch protection still has to see
|
||||
# `result`, `self-tests` and `build-isolation` green, and this lane
|
||||
# inherits every rule the reviewed lane has except the human.
|
||||
- name: Enable auto-merge
|
||||
if: ${{ !cancelled() && steps.pr.outputs.pull-request-number != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PKGS_BOT_TOKEN }}
|
||||
PR: ${{ steps.pr.outputs.pull-request-number }}
|
||||
run: |
|
||||
# Idempotent across re-runs of an updated PR: enabling twice errors.
|
||||
if [[ "$(gh pr view "$PR" -R "${{ github.repository }}" --json autoMergeRequest --jq '.autoMergeRequest != null')" == true ]]; then
|
||||
echo "auto-merge already enabled on #$PR"
|
||||
exit 0
|
||||
fi
|
||||
gh pr merge --auto --merge "$PR" -R "${{ github.repository }}"
|
||||
|
||||
- name: Notify Basecamp on failure
|
||||
if: failure() && env.BASECAMP_CHATBOT_URL != ''
|
||||
env:
|
||||
BASECAMP_CHATBOT_URL: ${{ secrets.BASECAMP_CHATBOT_URL }}
|
||||
run: |
|
||||
curl -s -o /dev/null \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg content \
|
||||
"🔴 <strong>Branch tracking failed</strong><br><a href=\"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\">View run</a>" \
|
||||
'{content: $content}')" \
|
||||
"$BASECAMP_CHATBOT_URL"
|
||||
@@ -23,6 +23,14 @@ The filesystem no longer encodes release policy. Instead:
|
||||
(`OMARCHY_RC_PINS=1`, which `omarchy-release rc` sets) may build it for rc — master's
|
||||
shipped pins can never overwrite an in-flight RC. The dev pair
|
||||
(`omarchy-dev`, `omarchy-settings-dev`) is pinned to `edge`
|
||||
- packages that follow a moving upstream branch (the dev pair on `quattro`,
|
||||
`omasnap-git` on `main`) still pin an exact commit in their PKGBUILD. A
|
||||
`git_branch` upstream watch moves that pin, and `"auto_merge": true` puts the
|
||||
package on the unattended lane: `track-branches.yml` opens the bump PR every
|
||||
two hours and auto-merges it once the build checks pass, so a branch tip
|
||||
reaches the edge channel without anyone clicking. No PKGBUILD may carry an
|
||||
unpinned git source (`tests/pinned-sources.sh`); a branch that has to be
|
||||
followed gets a watch, not a `#branch=` fragment
|
||||
- Omarchy owns every checked-in recipe; upstream watches update release metadata without replacing packaging or architecture support
|
||||
- packages can opt out of unscoped builds with `skip_build`; explicit `--package` builds remain available
|
||||
- packages follow direct upstream watches/providers in `.omarchy/package.json`, or a custom `.omarchy/upstream.sh` hook
|
||||
@@ -719,6 +727,7 @@ Fields:
|
||||
- `upstream`: optional direct release watch (see [Upstream watches](docs/upstream-sources.md)), or an existing GitHub, git-tag, npm, or Debian provider. GitHub architecture assets may be a string or an ordered array, and can be combined with disjoint versioned `sources` — see [Sync Upstream Releases](#sync-upstream-releases). Mutually exclusive with `.omarchy/upstream.sh`.
|
||||
- `min_release_age`: optional quarantine for upstream releases (`"24h"`, `"2d"`, or bare seconds). The newest release older than the window ships; anything younger waits, and a release whose age cannot be proven fails the sync. Bypass deliberately with `BYPASS_MIN_RELEASE_AGE=1 bin/sync-upstream <package>`.
|
||||
- `sync`: `false` records an existing manual maintenance hold. Held packages have no upstream watch/provider/hook and are excluded from automatic updates.
|
||||
- `auto_merge`: optional boolean; defaults to `false`. `true` moves the package's upstream updates from the reviewed 6-hourly sync PR to the unattended lane: `track-branches.yml` opens its bump PR and auto-merges it when CI is green. Meant for packages that follow a moving branch through a `git_branch` watch, where every tip is a release and there is nothing for a reviewer to read. Requires an upstream watch, provider, or hook.
|
||||
- `origin`: optional historical import provenance, with `aur` (package name) and `commit`. It does not control updates.
|
||||
- `release_ring`: optional. `fast` means the package is built directly for stable as well as edge, with the artifacts replicated into rc for parity. Packages without a ring build in edge and reach stable through the pipeline (`bin/repo advance`).
|
||||
- `channels`: optional array bounding where the package may be built (`edge`, `rc`, `stable`). Without the key a package is a member of every channel and follows the default build rules above; `bin/repo advance` refuses to carry a package anywhere it isn't a member.
|
||||
@@ -880,8 +889,17 @@ The repository includes GitHub workflows and systemd services for automated rele
|
||||
|
||||
#### GitHub Workflows
|
||||
|
||||
1. **sync-upstream.yml** (Every 6 hours): Watches direct upstream feeds and updates owned recipes. Successful package updates reach a PR even if another feed fails; failed recipes stay untouched and the workflow remains red.
|
||||
1. **sync-upstream.yml** (Every 6 hours): Watches direct upstream feeds and updates owned recipes on the reviewed lane. Successful package updates reach a PR even if another feed fails; failed recipes stay untouched and the workflow remains red.
|
||||
2. **sync-rebuilds.yml** (Every 6 hours): Bumps pkgrel for packages whose `rebuild_on` dependencies have moved in the official repositories and opens a PR.
|
||||
3. **track-branches.yml** (Every 2 hours): The unattended lane. Pins every `"auto_merge": true` package to the tip of its watched branch once its commit timestamp clears `min_release_age`, opens one PR for all of them, and enables auto-merge. Packages pinned from the same branch move together or not at all, including targeted syncs. The PR builds like any other; a tip that fails to build stays an open red PR until the next tick supersedes it.
|
||||
|
||||
The tracking PR and auto-merge use the PAT stored in `PKGS_BOT_TOKEN`, with
|
||||
Contents and Pull requests write access to this repository and an owner trusted
|
||||
to trigger builds. The existing controller PAT can be reused. No GitHub App is
|
||||
required. The built-in Actions `GITHUB_TOKEN` cannot drive the unattended
|
||||
build-and-publish chain, so the tracker requires this secret before it runs.
|
||||
The reviewed sync workflows continue to use `GITHUB_TOKEN` and require
|
||||
maintainer approval as before. See [setup instructions](docs/upstream-sources.md#enable-unattended-branch-updates).
|
||||
|
||||
To approve builds for an unvouched contributor's PR, apply **`build-approved`**.
|
||||
Until approval, the PR shows **Awaiting build approval** and its required
|
||||
|
||||
+103
-2
@@ -12,6 +12,7 @@ trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
export UPSTREAM_CACHE_DIR="$TEMP_DIR/watch-cache"
|
||||
|
||||
SPECIFIC_PACKAGES=()
|
||||
LANE=all
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -49,6 +50,14 @@ the bypass, so the resulting change still goes through a reviewed PR.
|
||||
Arguments:
|
||||
PACKAGE One or more package names to update (optional)
|
||||
|
||||
Options:
|
||||
--lane <reviewed|auto-merge|all>
|
||||
Which delivery lane to sync (default: all). Packages marked
|
||||
"auto_merge": true ride the unattended lane (the branch tracker
|
||||
opens and auto-merges their PR); everything else is reviewed.
|
||||
The scheduled workflows each pass their own lane so a moving
|
||||
branch tip never waits on a vendor release, or the reverse.
|
||||
|
||||
Commands:
|
||||
self-test Run the offline fixture tests for release selection, the
|
||||
quarantine backstop, and metadata parsing
|
||||
@@ -65,6 +74,14 @@ while [[ $# -gt 0 ]]; do
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--lane)
|
||||
LANE="${2:-}"
|
||||
case "$LANE" in
|
||||
reviewed|auto-merge|all) ;;
|
||||
*) print_error "Invalid --lane '$LANE' (expected reviewed, auto-merge, or all)"; exit 1 ;;
|
||||
esac
|
||||
shift 2
|
||||
;;
|
||||
--*)
|
||||
print_error "Unknown option: $1"
|
||||
exit 1
|
||||
@@ -1042,16 +1059,100 @@ if [[ ${#SPECIFIC_PACKAGES[@]} -gt 0 && "${SPECIFIC_PACKAGES[0]}" == "self-test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Packages that pin the same upstream branch move together or not at all.
|
||||
# The watch reads one shared clone per run, so they only disagree when one
|
||||
# package's update failed after the tip was chosen (a checksum fetch, say).
|
||||
# Leaving the other one advanced would ship omarchy-dev and
|
||||
# omarchy-settings-dev from different commits, which is exactly the skew the
|
||||
# release pair's lockstep guard exists to prevent. Restore the packages that
|
||||
# moved and count the group as failed; the next run tries again.
|
||||
declare -A BEFORE_SYNC=()
|
||||
|
||||
snapshot_package() {
|
||||
local package="$1" pkgbuild="$PKGBUILDS_DIR/$1/PKGBUILD"
|
||||
[[ -f "$pkgbuild" ]] || return 0
|
||||
mkdir -p "$TEMP_DIR/before"
|
||||
cp "$pkgbuild" "$TEMP_DIR/before/$package"
|
||||
BEFORE_SYNC["$package"]=1
|
||||
}
|
||||
|
||||
branch_watch_key() {
|
||||
local package_dir="$1"
|
||||
jq -r '
|
||||
(.upstream.watch? | objects | select(has("git_branch")))
|
||||
| "\(.git_branch)#\(.branch)"
|
||||
' "$package_dir/.omarchy/package.json" 2>/dev/null
|
||||
}
|
||||
|
||||
enforce_branch_lockstep() {
|
||||
local package key
|
||||
declare -A groups=()
|
||||
for package in "${!BEFORE_SYNC[@]}"; do
|
||||
key=$(branch_watch_key "$PKGBUILDS_DIR/$package")
|
||||
[[ -n "$key" ]] || continue
|
||||
groups["$key"]+="$package "
|
||||
done
|
||||
for key in "${!groups[@]}"; do
|
||||
local members commits pin
|
||||
read -r -a members <<<"${groups[$key]}"
|
||||
(( ${#members[@]} > 1 )) || continue
|
||||
commits=$(for package in "${members[@]}"; do
|
||||
pin=$(grep -m1 -E '^_commit=' "$PKGBUILDS_DIR/$package/PKGBUILD" | cut -d= -f2- | tr -d "\"'" || true)
|
||||
printf '%s\n' "${pin:-missing:$package}"
|
||||
done | sort -u | grep -c .)
|
||||
(( commits > 1 )) || continue
|
||||
print_error "Lockstep violation on $key: ${members[*]} pin different commits; restoring all of them"
|
||||
for package in "${members[@]}"; do
|
||||
if ! cmp -s "$TEMP_DIR/before/$package" "$PKGBUILDS_DIR/$package/PKGBUILD"; then
|
||||
cp "$TEMP_DIR/before/$package" "$PKGBUILDS_DIR/$package/PKGBUILD"
|
||||
# Not ((--UPDATED)): an arithmetic command that evaluates to zero
|
||||
# returns 1, and under errexit that would end the run right here.
|
||||
UPDATED=$((UPDATED > 0 ? UPDATED - 1 : 0))
|
||||
fi
|
||||
done
|
||||
((++FAILED))
|
||||
done
|
||||
}
|
||||
|
||||
sync_in_lane() {
|
||||
local package="$1" package_dir="$PKGBUILDS_DIR/$1"
|
||||
snapshot_package "$package"
|
||||
if [[ -d "$package_dir" ]] && ! package_in_lane "$package_dir" "$LANE"; then
|
||||
print_info "Skipping $package: not in the $LANE lane"
|
||||
((++SKIPPED))
|
||||
return 0
|
||||
fi
|
||||
sync_package "$package"
|
||||
}
|
||||
|
||||
if [[ ${#SPECIFIC_PACKAGES[@]} -gt 0 ]]; then
|
||||
SPECIFIC_MODE=true
|
||||
# A targeted run is still a branch update: include every sibling watching
|
||||
# the same branch, otherwise the lockstep check never sees the omitted one.
|
||||
declare -A selected=() selected_branches=()
|
||||
for package in "${SPECIFIC_PACKAGES[@]}"; do
|
||||
sync_package "$package"
|
||||
selected["$package"]=1
|
||||
key=$(branch_watch_key "$PKGBUILDS_DIR/$package" || true)
|
||||
[[ -z "$key" ]] || selected_branches["$key"]=1
|
||||
done
|
||||
for package_dir in "$PKGBUILDS_DIR"/*; do
|
||||
[[ -f "$package_dir/PKGBUILD" ]] || continue
|
||||
package=${package_dir##*/}
|
||||
[[ -z "${selected[$package]:-}" ]] || continue
|
||||
key=$(branch_watch_key "$package_dir" || true)
|
||||
if [[ -n "$key" && -n "${selected_branches[$key]:-}" ]]; then
|
||||
SPECIFIC_PACKAGES+=("$package")
|
||||
fi
|
||||
done
|
||||
for package in "${SPECIFIC_PACKAGES[@]}"; do
|
||||
sync_in_lane "$package"
|
||||
done
|
||||
else
|
||||
while IFS= read -r package; do
|
||||
sync_package "$package"
|
||||
sync_in_lane "$package"
|
||||
done < <(packages_for_upstream_sync)
|
||||
fi
|
||||
enforce_branch_lockstep
|
||||
|
||||
echo ""
|
||||
if [[ $FAILED -gt 0 ]]; then
|
||||
|
||||
@@ -48,8 +48,63 @@ pkgrel to 1, but the complete epoch:pkgver-pkgrel must still increase.
|
||||
|
||||
GitHub releases exclude drafts and prereleases unless `allow_prerelease` is true.
|
||||
Existing `min_release_age` policies apply: a feed without a verifiable publication
|
||||
time cannot bypass a configured hold. Git branch watches derive a commit count
|
||||
and date from the actual branch history and write an immutable source pin.
|
||||
time cannot bypass a configured hold.
|
||||
|
||||
## Branch watches
|
||||
|
||||
A `git_branch` watch treats every commit on a branch as a release and writes an
|
||||
immutable pin (`"_commit": "{commit}"`) so the recipe never carries a moving
|
||||
`#branch=` source; `tests/pinned-sources.sh` enforces that. The clone is bare,
|
||||
blobless and single-branch, read only with git, and shared by every package
|
||||
that watches the same branch in one run, so two recipes pinned from it always
|
||||
see the same commit. Values available to `version`:
|
||||
|
||||
- `{date}` (default), `{count}` (commits on the branch), `{commit}`
|
||||
(`{commit:.7}` for the short form)
|
||||
- with `tag_pattern` (a regular expression with a named `version` group,
|
||||
matched against whole tags): `{tag}`, `{version}` from that tag, and
|
||||
`{distance}`, the number of commits past it. Only tags in the pinned
|
||||
commit's own history count, so a release cut on another branch is ignored.
|
||||
|
||||
`{version}.r{distance}.g{commit:.7}` gives `1.21.0.r15.gabc1234`, which pacman
|
||||
orders above the `1.21.0` release it follows and below `1.21.1`; `omasnap-git`
|
||||
uses it. The Omarchy dev pair uses `{version}.r{count}.g{commit:.7}` instead
|
||||
because its published history counted every commit and the number must never
|
||||
go down.
|
||||
|
||||
`min_release_age` holds a branch tip until its commit timestamp is old enough.
|
||||
A fresh tip leaves the existing pin alone; the watch never walks backward to
|
||||
an older commit. This uses Git's committer date, not the time a commit was
|
||||
pushed. `BYPASS_MIN_RELEASE_AGE=1` bypasses the hold.
|
||||
|
||||
Packages marked `"auto_merge": true` ride the unattended lane
|
||||
(`track-branches.yml`) instead of the reviewed sync PR: their bump PR is opened
|
||||
and auto-merged as soon as the build checks pass. `bin/sync-upstream --lane
|
||||
reviewed|auto-merge|all` selects a lane; the scheduled workflows each pass their
|
||||
own. Packages that pin the same branch move in lockstep: if one of them fails
|
||||
to update, the run restores the others and reports the group as failed. A
|
||||
targeted sync includes the other packages watching that branch, so requesting
|
||||
only `omarchy-dev` also updates `omarchy-settings-dev`.
|
||||
|
||||
### Enable unattended branch updates
|
||||
|
||||
The schedule already runs in GitHub Actions; no server cron job is needed.
|
||||
It uses a personal access token so its PRs trigger builds and its merges trigger
|
||||
publishing without manual approval. No GitHub App is required.
|
||||
|
||||
1. Use a fine-grained PAT with access to **omacom/omarchy-pkgs** and repository
|
||||
**Contents: Read and write** and **Pull requests: Read and write** permissions.
|
||||
Its owner must be trusted by the build workflow (for example, a collaborator).
|
||||
The existing controller PAT can be reused when it has these permissions.
|
||||
2. In the repository's
|
||||
[Actions secrets](https://github.com/omacom/omarchy-pkgs/settings/secrets/actions),
|
||||
save the PAT as `PKGS_BOT_TOKEN`. Update this secret when the token is rotated
|
||||
or expires. The built-in Actions `GITHUB_TOKEN` cannot run this unattended chain.
|
||||
3. Keep **Allow auto-merge** enabled and require `result`, `self-tests`, and
|
||||
`build-isolation` on `master`; the tracker does not request a protection bypass.
|
||||
4. After merging the tracker, run **Track upstream branches** once from Actions
|
||||
to verify that its PR builds, auto-merges, and starts **Publish merged packages**.
|
||||
Subsequent runs happen every two hours.
|
||||
|
||||
Checksums retain their algorithms (SHA256, SHA512, BLAKE2, etc.) and source order.
|
||||
Changed git sources are hashed with makepkg's git-archive convention. Unchanged
|
||||
@@ -131,6 +186,8 @@ in `origin` and has no effect on release selection.
|
||||
| `localsend` | github | [localsend/localsend](https://github.com/localsend/localsend) |
|
||||
| `localsend-bin` | github | [localsend/localsend](https://github.com/localsend/localsend) |
|
||||
| `macbook12-spi-driver-dkms` | git_branch | [https://github.com/marc-git/macbook12-spi-driver.git](https://github.com/marc-git/macbook12-spi-driver.git) |
|
||||
| `omarchy-dev`, `omarchy-settings-dev` | git_branch (auto-merge) | [https://github.com/basecamp/omarchy.git](https://github.com/basecamp/omarchy.git) `quattro` |
|
||||
| `omasnap-git` | git_branch (auto-merge) | [https://github.com/omacom/omasnap.git](https://github.com/omacom/omasnap.git) `main` |
|
||||
| `makima-bin` | github | [cyber-sushi/makima](https://github.com/cyber-sushi/makima) |
|
||||
| `minecraft-launcher` | archive | [https://launcher.mojang.com/download/Minecraft.deb](https://launcher.mojang.com/download/Minecraft.deb) |
|
||||
| `nautilus-dropbox` | github | [dropbox/nautilus-dropbox](https://github.com/dropbox/nautilus-dropbox) |
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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,6 +184,53 @@ def matches(watch, text, extra=None, full=False):
|
||||
yield candidate(watch, {**(extra or {}), **match.groupdict()})
|
||||
|
||||
|
||||
def git_branch_tip(url, branch, tag_pattern, cache):
|
||||
"""Describe the current tip of an upstream branch:
|
||||
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.
|
||||
select_release applies the age hold to this tip, without walking back
|
||||
into history (which could select a commit from a merged side branch).
|
||||
"""
|
||||
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)]
|
||||
commit = run([*git, "rev-parse", "HEAD"], text=True).strip()
|
||||
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):
|
||||
provider = validate(watch)
|
||||
feed = watch[provider]
|
||||
@@ -199,13 +258,8 @@ 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)
|
||||
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 +533,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), min_age, bypass=bypass)
|
||||
if release is None:
|
||||
return {"status": "skipped", "reason": "minimum release age"}
|
||||
current = scalar(before, "pkgver")
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
{ "source": "local", "channels": ["edge"] }
|
||||
{
|
||||
"source": "local",
|
||||
"channels": ["edge"],
|
||||
"auto_merge": true,
|
||||
"min_release_age": "30m",
|
||||
"upstream": {
|
||||
"watch": {
|
||||
"git_branch": "https://github.com/basecamp/omarchy.git",
|
||||
"branch": "quattro",
|
||||
"tag_pattern": "v(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+)",
|
||||
"version": "{version}.r{count}.g{commit:.7}",
|
||||
"variables": {
|
||||
"_commit": "{commit}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
# Maintainer: Ryan Hughes <ryan@omarchy.org>
|
||||
pkgname='omarchy-dev'
|
||||
pkgver=4.0.0.r847.g4de185b
|
||||
pkgver=4.0.0.r6646.gbf44355
|
||||
pkgrel=1
|
||||
_pkgver_base=4.0.0
|
||||
_pkgver_base_tag=v3.8.2
|
||||
# Pinned by the upstream watch in .omarchy/package.json (bin/sync-upstream):
|
||||
# every quattro tip becomes a commit pin here, so the package is versioned,
|
||||
# checksummed and built exactly like a release, just more often. The r-number
|
||||
# is the branch's total commit count, not the distance from the last tag: the
|
||||
# published history used the total, and pacman must never see it go down.
|
||||
_commit=bf44355a985b1bb82cb33df47fc17f765f3b2c4f
|
||||
pkgdesc='Beautiful, modern, and opinionated Arch Linux by DHH (quattro branch tip)'
|
||||
# The payload is architecture-independent, but the dependency set is not: the
|
||||
# boot stack differs per architecture (see depends_x86_64 / depends_aarch64),
|
||||
@@ -73,30 +77,16 @@ makedepends=(
|
||||
'git'
|
||||
)
|
||||
|
||||
# Source: quattro branch tip by default; set OMARCHY_SRC=/path/to/checkout
|
||||
# to build from a local source tree instead (mirrors omarchy-zsh/omarchy-fish DX).
|
||||
# Source: the pinned quattro commit. Set OMARCHY_SRC=/path/to/checkout to
|
||||
# build from a local source tree instead (mirrors omarchy-zsh/omarchy-fish DX);
|
||||
# the arrays are emptied below so nothing is downloaded in that case.
|
||||
source=("omarchy::git+https://github.com/basecamp/omarchy.git#commit=${_commit}")
|
||||
sha256sums=('7a90239fd67124279817d614fa6aa19554f40796c523ba698669fbf57fa41668')
|
||||
if [[ -n "${OMARCHY_SRC:-}" ]]; then
|
||||
source=()
|
||||
sha256sums=()
|
||||
else
|
||||
source=("omarchy::git+https://github.com/basecamp/omarchy.git#branch=quattro")
|
||||
sha256sums=('SKIP')
|
||||
fi
|
||||
|
||||
pkgver() {
|
||||
cd "$srcdir/omarchy"
|
||||
|
||||
local commit_count commit_hash
|
||||
if git rev-parse --verify "${_pkgver_base_tag}^{commit}" >/dev/null 2>&1; then
|
||||
commit_count=$(git rev-list --count "${_pkgver_base_tag}..HEAD")
|
||||
else
|
||||
commit_count=$(git rev-list --count HEAD)
|
||||
fi
|
||||
commit_hash=$(git rev-parse --short=7 HEAD)
|
||||
|
||||
printf "%s.r%s.g%s" "$_pkgver_base" "$commit_count" "$commit_hash"
|
||||
}
|
||||
|
||||
prepare() {
|
||||
if [[ -n "${OMARCHY_SRC:-}" ]]; then
|
||||
rm -rf "$srcdir/omarchy"
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
{ "source": "local", "channels": ["edge"] }
|
||||
{
|
||||
"source": "local",
|
||||
"channels": ["edge"],
|
||||
"auto_merge": true,
|
||||
"min_release_age": "30m",
|
||||
"upstream": {
|
||||
"watch": {
|
||||
"git_branch": "https://github.com/basecamp/omarchy.git",
|
||||
"branch": "quattro",
|
||||
"tag_pattern": "v(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+)",
|
||||
"version": "{version}.r{count}.g{commit:.7}",
|
||||
"variables": {
|
||||
"_commit": "{commit}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
# Maintainer: Ryan Hughes <ryan@omarchy.org>
|
||||
pkgname='omarchy-settings-dev'
|
||||
pkgver=4.0.0.r847.g4de185b
|
||||
pkgrel=2
|
||||
_pkgver_base=4.0.0
|
||||
_pkgver_base_tag=v3.8.2
|
||||
pkgver=4.0.0.r6646.gbf44355
|
||||
pkgrel=1
|
||||
# Pinned by the upstream watch in .omarchy/package.json (bin/sync-upstream):
|
||||
# every quattro tip becomes a commit pin here, so the package is versioned,
|
||||
# checksummed and built exactly like a release, just more often. The r-number
|
||||
# is the branch's total commit count, not the distance from the last tag: the
|
||||
# published history used the total, and pacman must never see it go down.
|
||||
_commit=bf44355a985b1bb82cb33df47fc17f765f3b2c4f
|
||||
pkgdesc='Omarchy user defaults, /etc/skel content, fonts, plymouth theme, and support helpers (quattro branch tip)'
|
||||
# Arch-specific because the shipped /etc tree is not the same on every
|
||||
# architecture: the Limine, mkinitcpio, zram and oomd drop-ins belong to the
|
||||
@@ -109,30 +113,16 @@ _etc_override_paths=(
|
||||
'etc/plymouth/plymouthd.conf'
|
||||
)
|
||||
|
||||
# Source: quattro branch tip by default; set OMARCHY_SRC=/path/to/checkout
|
||||
# to build from a local source tree instead (mirrors omarchy-zsh/omarchy-fish DX).
|
||||
# Source: the pinned quattro commit. Set OMARCHY_SRC=/path/to/checkout to
|
||||
# build from a local source tree instead (mirrors omarchy-zsh/omarchy-fish DX);
|
||||
# the arrays are emptied below so nothing is downloaded in that case.
|
||||
source=("omarchy::git+https://github.com/basecamp/omarchy.git#commit=${_commit}")
|
||||
sha256sums=('7a90239fd67124279817d614fa6aa19554f40796c523ba698669fbf57fa41668')
|
||||
if [[ -n "${OMARCHY_SRC:-}" ]]; then
|
||||
source=()
|
||||
sha256sums=()
|
||||
else
|
||||
source=("omarchy::git+https://github.com/basecamp/omarchy.git#branch=quattro")
|
||||
sha256sums=('SKIP')
|
||||
fi
|
||||
|
||||
pkgver() {
|
||||
cd "$srcdir/omarchy"
|
||||
|
||||
local commit_count commit_hash
|
||||
if git rev-parse --verify "${_pkgver_base_tag}^{commit}" >/dev/null 2>&1; then
|
||||
commit_count=$(git rev-list --count "${_pkgver_base_tag}..HEAD")
|
||||
else
|
||||
commit_count=$(git rev-list --count HEAD)
|
||||
fi
|
||||
commit_hash=$(git rev-parse --short=7 HEAD)
|
||||
|
||||
printf "%s.r%s.g%s" "$_pkgver_base" "$commit_count" "$commit_hash"
|
||||
}
|
||||
|
||||
prepare() {
|
||||
if [[ -n "${OMARCHY_SRC:-}" ]]; then
|
||||
rm -rf "$srcdir/omarchy"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"source": "local",
|
||||
"channels": ["edge"],
|
||||
"auto_merge": true,
|
||||
"min_release_age": "30m",
|
||||
"upstream": {
|
||||
"watch": {
|
||||
"git_branch": "https://github.com/omacom/omasnap.git",
|
||||
"branch": "main",
|
||||
"tag_pattern": "v(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+)",
|
||||
"version": "{version}.r{distance}.g{commit:.7}",
|
||||
"variables": {
|
||||
"_commit": "{commit}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# Maintainer: Ryan Hughes <ryan@omarchy.org>
|
||||
# The main-branch build of omasnap. Pinned by the upstream watch in
|
||||
# .omarchy/package.json (bin/sync-upstream): every main tip becomes a commit
|
||||
# pin here, versioned <last tag>.r<commits since>.g<sha> so it sorts above the
|
||||
# tagged release it follows and below the next one.
|
||||
|
||||
pkgname=omasnap-git
|
||||
pkgver=1.21.0.r76.g614cdf5
|
||||
pkgrel=1
|
||||
_commit=614cdf55b42a43c1dcee2c85cd01e4764a52bdae
|
||||
pkgdesc="Native Wayland screenshot and annotation overlay for Hyprland (main branch)"
|
||||
arch=('x86_64' 'aarch64')
|
||||
url="https://github.com/omacom/omasnap"
|
||||
license=('MIT' 'OFL-1.1')
|
||||
depends=(
|
||||
'hyprland'
|
||||
'layer-shell-qt'
|
||||
'qt6-base'
|
||||
'tesseract'
|
||||
'tesseract-data-eng'
|
||||
'wayland'
|
||||
'wl-clipboard'
|
||||
)
|
||||
makedepends=(
|
||||
'cmake'
|
||||
'git'
|
||||
'ninja'
|
||||
'pkgconf'
|
||||
'wayland-protocols'
|
||||
)
|
||||
provides=('omasnap')
|
||||
conflicts=('omasnap')
|
||||
options=('!debug')
|
||||
|
||||
source=("omasnap::git+$url.git#commit=${_commit}")
|
||||
sha256sums=('952539c68a81ac373c0f2d5fc084106d3419ee3e5ec2d544d02b510a3f416484')
|
||||
|
||||
build() {
|
||||
cmake -S omasnap -B build -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr
|
||||
cmake --build build --parallel
|
||||
}
|
||||
|
||||
check() {
|
||||
# The smoke suite fsyncs its working documents under /tmp. On the CI
|
||||
# droplets the build leaves about a gigabyte of dirty pages, and the
|
||||
# flush that starts a few seconds into the suite makes those fsyncs stall
|
||||
# long enough to overrun the suite's 5-second settle windows. Flush first.
|
||||
local runtime_dir status started
|
||||
started=$(date +%s%N); sync
|
||||
echo "flushed dirty pages in $(( ($(date +%s%N) - started) / 1000000 )) ms"
|
||||
runtime_dir=$(mktemp -d /dev/shm/omasnap-runtime.XXXXXX)
|
||||
if QT_QPA_PLATFORM=offscreen QT_FORCE_STDERR_LOGGING=1 \
|
||||
XDG_RUNTIME_DIR="$runtime_dir" \
|
||||
./build/omasnap-smoke "$srcdir/omasnap-smoke-output"; then
|
||||
status=0
|
||||
else
|
||||
status=$?
|
||||
echo "omasnap-smoke exited with status $status" >&2
|
||||
fi
|
||||
rm -r -- "$runtime_dir"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
package() {
|
||||
cmake --install build --prefix "$pkgdir/usr"
|
||||
install -Dm644 omasnap/README.md \
|
||||
"$pkgdir/usr/share/doc/omasnap/README.md"
|
||||
install -Dm644 omasnap/LICENSE \
|
||||
"$pkgdir/usr/share/licenses/omasnap/LICENSE"
|
||||
}
|
||||
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,160 @@ 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_holds_the_tip_instead_of_selecting_history(self):
|
||||
repo, shas = self.branch_fixture(fresh_tip=True)
|
||||
watch = {'git_branch': 'https://example.test/tool.git', 'branch': 'main'}
|
||||
with self.redirect_clone(repo):
|
||||
releases = w.discover(watch, self.fetch)
|
||||
self.assertEqual(w.select_release(releases)['values']['commit'], shas[-1])
|
||||
self.assertIsNone(w.select_release(releases, min_age=3600))
|
||||
self.assertEqual(w.select_release(releases, min_age=3600, bypass=True)['values']['commit'], shas[-1])
|
||||
|
||||
def branch_sync_fixture(self):
|
||||
repo, shas = self.branch_fixture()
|
||||
for directory in ['bin', 'helpers']:
|
||||
shutil.copytree(ROOT / directory, self.root / directory)
|
||||
for name in ['dev', 'settings-dev']:
|
||||
package = self.root / 'pkgbuilds' / name
|
||||
(package / '.omarchy').mkdir(parents=True)
|
||||
(package / '.omarchy/package.json').write_text(json.dumps({
|
||||
'source': 'local', 'auto_merge': True, 'upstream': {'watch': {
|
||||
'git_branch': 'https://example.test/tool.git', 'branch': 'main',
|
||||
'tag_pattern': r'v(?P<version>[0-9.]+)',
|
||||
'version': '{version}.r{count}.g{commit:.7}',
|
||||
'variables': {'_commit': '{commit}'}}}}))
|
||||
(package / 'PKGBUILD').write_text(f'''pkgname={name}
|
||||
pkgver=1.0.0
|
||||
pkgrel=1
|
||||
_commit={shas[1]}
|
||||
arch=('any')
|
||||
source=("tool::git+https://example.test/tool.git#commit=${{_commit}}")
|
||||
sha256sums=('old')
|
||||
''')
|
||||
stub = self.root / 'stub'
|
||||
stub.mkdir()
|
||||
git = stub / 'git'
|
||||
git.write_text('''#!/usr/bin/env python3
|
||||
import os, sys
|
||||
args = [os.environ['BRANCH_FIXTURE'] if arg == 'https://example.test/tool.git' else arg for arg in sys.argv[1:]]
|
||||
os.execv(os.environ['REAL_GIT'], ['git', *args])
|
||||
''')
|
||||
git.chmod(0o755)
|
||||
env = {**os.environ, 'PATH': str(stub) + os.pathsep + os.environ['PATH'],
|
||||
'BRANCH_FIXTURE': f'file://{repo}', 'REAL_GIT': shutil.which('git')}
|
||||
def sync(*args):
|
||||
return subprocess.run([str(self.root / 'bin/sync-upstream'), *args],
|
||||
env=env, text=True, capture_output=True)
|
||||
return self.root / 'pkgbuilds', shas[-1], sync
|
||||
|
||||
def test_targeted_branch_sync_updates_siblings_and_then_noops(self):
|
||||
packages, tip, sync = self.branch_sync_fixture()
|
||||
result = sync('--lane', 'auto-merge', 'dev')
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
for package in packages.iterdir():
|
||||
recipe = w.read_recipe(package / 'PKGBUILD')
|
||||
self.assertEqual(w.scalar(recipe, '_commit'), tip)
|
||||
self.assertEqual(w.scalar(recipe, 'pkgver'), f'1.1.0.r5.g{tip[:7]}')
|
||||
self.assertRegex(recipe['sha256sums'][0], r'^[0-9a-f]{64}$')
|
||||
self.assertIn('Updated: 2', result.stdout)
|
||||
result = sync('--lane', 'auto-merge', 'dev')
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn('Updated: 0', result.stdout)
|
||||
|
||||
def test_branch_sync_rolls_back_when_a_sibling_fails(self):
|
||||
packages, tip, sync = self.branch_sync_fixture()
|
||||
broken = packages / 'settings-dev/PKGBUILD'
|
||||
broken.write_text(broken.read_text().replace("sha256sums=('old')", 'sha256sums=()'))
|
||||
before = {p: p.read_bytes() for p in packages.glob('*/PKGBUILD')}
|
||||
result = sync('--lane', 'auto-merge', 'dev')
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn('Lockstep violation', result.stdout + result.stderr)
|
||||
self.assertIn('Updated: 0', result.stdout)
|
||||
self.assertEqual(before, {p: p.read_bytes() for p in before})
|
||||
|
||||
def test_reviewed_lane_leaves_auto_merge_packages_untouched(self):
|
||||
packages, tip, sync = self.branch_sync_fixture()
|
||||
before = {p: p.read_bytes() for p in packages.glob('*/PKGBUILD')}
|
||||
result = sync('--lane', 'reviewed')
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn('Updated: 0', result.stdout)
|
||||
self.assertEqual(before, {p: p.read_bytes() for p in before})
|
||||
|
||||
def test_different_lanes_cannot_split_a_branch_pair(self):
|
||||
packages, tip, sync = self.branch_sync_fixture()
|
||||
metadata = packages / 'settings-dev/.omarchy/package.json'
|
||||
metadata.write_text(metadata.read_text().replace('"auto_merge": true', '"auto_merge": false'))
|
||||
before = {p: p.read_bytes() for p in packages.glob('*/PKGBUILD')}
|
||||
result = sync('--lane', 'auto-merge', 'dev')
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn('Lockstep violation', result.stdout + result.stderr)
|
||||
self.assertEqual(before, {p: p.read_bytes() for p in before})
|
||||
|
||||
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