Merge current package defaults into clipboard fix
This commit is contained in:
commit
d9748c6a15
737 files changed
+216530
-1514
No files matched your search
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Self-test for helpers/artifact-helpers.sh: package files survive the
|
||||
# artifact hop between build-pr.yml and publish.yml with makepkg's names
|
||||
# intact, including the colon an epoch puts in them.
|
||||
set -euo pipefail
|
||||
ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
||||
source "$ROOT/helpers/artifact-helpers.sh"
|
||||
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
||||
pass() { echo "PASS: $1"; }
|
||||
fail() { echo "FAIL: $1"; exit 1; }
|
||||
|
||||
EPOCH='cursor-cli-1:2026.09.18.1.9a7762b-1-x86_64.pkg.tar.zst'
|
||||
PLAIN='beta-1.0-1-x86_64.pkg.tar.zst'
|
||||
mkdir -p "$T/built" "$T/artifact" "$T/out"
|
||||
echo epoch > "$T/built/$EPOCH"
|
||||
echo plain > "$T/built/$PLAIN"
|
||||
echo sig > "$T/built/$PLAIN.sig"
|
||||
echo db > "$T/built/omarchy.db.tar.zst"
|
||||
|
||||
pack_packages "$T/built" "$T/artifact/packages.tar" || fail "pack"
|
||||
[[ "$(tar -tf "$T/artifact/packages.tar" | sort | tr '\n' ' ')" == "$PLAIN $EPOCH " ]] \
|
||||
&& pass "packages.tar holds the packages only, no signature or database" || fail "tar contents: $(tar -tf "$T/artifact/packages.tar" | tr '\n' ' ')"
|
||||
[[ "$(ls "$T/artifact")" == "packages.tar" ]] && pass "the artifact path carries no colon" || fail "artifact listing"
|
||||
|
||||
unpack_packages "$T/artifact" "$T/out" || fail "unpack"
|
||||
[[ "$(ls "$T/out" | sort | tr '\n' ' ')" == "$PLAIN $EPOCH " && "$(cat "$T/out/$EPOCH")" == epoch ]] \
|
||||
&& pass "epoch filename and bytes survive the round trip" || fail "round trip: $(ls "$T/out" | tr '\n' ' ')"
|
||||
|
||||
# An artifact uploaded before packing existed: bare package files.
|
||||
mkdir -p "$T/old" "$T/out2"; cp "$T/built"/*.pkg.tar.zst "$T/old/"
|
||||
unpack_packages "$T/old" "$T/out2" && [[ "$(ls "$T/out2" | sort | tr '\n' ' ')" == "$PLAIN $EPOCH " ]] \
|
||||
&& pass "a bare pre-packing artifact still unpacks" || fail "bare artifact"
|
||||
|
||||
# The workflows call these bare under `bash -e`, so any non-zero status
|
||||
# inside them ends the step. (The first version used `shopt -p nullglob`,
|
||||
# which exits 1 when the option is off; the tests above never saw it because
|
||||
# `||` suppresses errexit.)
|
||||
mkdir -p "$T/out4" "$T/out5"
|
||||
bash -e -c "source '$ROOT/helpers/artifact-helpers.sh'; pack_packages '$T/built' '$T/out4/packages.tar'; tar -tf '$T/out4/packages.tar' >/dev/null; unpack_packages '$T/out4' '$T/out5'" \
|
||||
&& [[ "$(ls "$T/out5" | sort | tr '\n' ' ')" == "$PLAIN $EPOCH " ]] \
|
||||
&& pass "pack and unpack succeed under bash -e, as the workflows call them" || fail "bash -e"
|
||||
|
||||
mkdir -p "$T/empty"
|
||||
if pack_packages "$T/empty" "$T/x.tar" 2>/dev/null; then fail "packing an empty build dir should fail"; else pass "empty build dir refused"; fi
|
||||
if unpack_packages "$T/empty" "$T/out3" 2>/dev/null; then fail "an empty artifact should fail"; else pass "empty artifact refused"; fi
|
||||
@@ -89,11 +89,17 @@ EOF
|
||||
fixture consumer "checkdepends_${TEST_ARCH}=('broken')"
|
||||
fixture transitive "makedepends=('consumer')"
|
||||
fixture independent ''
|
||||
if run_build transitive consumer broken independent > "$TEST_ROOT/failure.log" 2>&1; then
|
||||
failure_status=0
|
||||
OMARCHY_BUILD_RESULT_DIR="$TEST_ROOT/results" run_build transitive consumer broken independent > "$TEST_ROOT/failure.log" 2>&1 || failure_status=$?
|
||||
if [[ "$failure_status" != 2 ]]; then
|
||||
cat "$TEST_ROOT/failure.log"
|
||||
echo 'FAIL: a failed prerequisite did not fail the run' >&2
|
||||
echo "FAIL: expected completed package failure (2), got $failure_status" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -f "$TEST_ROOT/results/complete" ]]
|
||||
[[ $(cat "$TEST_ROOT/results/failed") == broken ]]
|
||||
[[ $(cat "$TEST_ROOT/results/blocked") == $'transitive\nconsumer' || $(cat "$TEST_ROOT/results/blocked") == $'consumer\ntransitive' ]]
|
||||
[[ $(cat "$TEST_ROOT/results/artifacts") == "independent-1-1-$TEST_ARCH.pkg.tar.zst" ]]
|
||||
grep -q 'consumer blocked by unsuccessful dependency: broken' "$TEST_ROOT/failure.log"
|
||||
grep -q 'transitive blocked by unsuccessful dependency: consumer' "$TEST_ROOT/failure.log"
|
||||
compgen -G "$TEST_ROOT/build-output/edge/$TEST_ARCH/independent-1-1-*.pkg.tar.zst" >/dev/null
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } = require('node:fs');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const root = join(__dirname, '..');
|
||||
const workflow = readFileSync(join(root, '.github/workflows/builder-images.yml'), 'utf8');
|
||||
|
||||
function fixture(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'builder-image-test-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
mkdirSync(join(directory, 'bin'));
|
||||
mkdirSync(join(directory, 'build'));
|
||||
cpSync(join(root, 'helpers'), join(directory, 'helpers'), { recursive: true });
|
||||
cpSync(join(root, 'bin/builder-image'), join(directory, 'bin/builder-image'));
|
||||
writeFileSync(join(directory, 'build/Dockerfile'), 'FROM scratch\nCOPY input /input\n');
|
||||
writeFileSync(join(directory, 'build/input'), 'original input\n');
|
||||
const engine = join(directory, 'engine');
|
||||
mkdirSync(engine);
|
||||
const log = join(directory, 'engine.jsonl');
|
||||
writeFileSync(join(engine, 'docker'), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(2);
|
||||
fs.appendFileSync(process.env.ENGINE_LOG, JSON.stringify(args) + '\\n');
|
||||
if (args[0] === 'manifest' && process.env.PRIVATE_IMAGE === '1') process.exit(1);
|
||||
if (args[0] === 'push' && process.env.PUSH_FAIL === '1') process.exit(1);
|
||||
if (args[0] === 'image' && args[1] === 'inspect') console.log('ghcr.io/omacom/omarchy-pkg-builder@sha256:' + 'a'.repeat(64));
|
||||
`);
|
||||
chmodSync(join(engine, 'docker'), 0o755);
|
||||
const env = {
|
||||
...process.env, PATH: `${engine}:${process.env.PATH}`, CONTAINER_ENGINE: 'docker',
|
||||
ENGINE_LOG: log, ARCH: 'x86_64', MIRROR: 'edge',
|
||||
};
|
||||
const run = (args, extraEnv = {}) => spawnSync(join(directory, 'bin/builder-image'), args, {
|
||||
cwd: directory, env: { ...env, ...extraEnv }, encoding: 'utf8',
|
||||
});
|
||||
const key = (...args) => {
|
||||
const result = run(['key', ...args]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return result.stdout.trim();
|
||||
};
|
||||
const calls = () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
return { directory, env, run, key, calls };
|
||||
}
|
||||
|
||||
test('image keys are stable across checkout location and timestamp changes', t => {
|
||||
const a = fixture(t);
|
||||
const b = fixture(t);
|
||||
const key = a.key();
|
||||
assert.match(key, /^v1-x86_64-edge-[a-f0-9]{64}$/);
|
||||
utimesSync(join(b.directory, 'build/input'), new Date(0), new Date(0));
|
||||
assert.equal(b.key(), key);
|
||||
});
|
||||
|
||||
test('image keys separate architectures, mirrors, content, modes and symlink targets', t => {
|
||||
const f = fixture(t);
|
||||
const keys = new Set([f.key(), f.key('--arch', 'aarch64'), f.key('--mirror', 'rc'), f.key('--mirror', 'stable')]);
|
||||
writeFileSync(join(f.directory, 'build/input'), 'new input\n');
|
||||
keys.add(f.key());
|
||||
chmodSync(join(f.directory, 'build/input'), 0o755);
|
||||
keys.add(f.key());
|
||||
symlinkSync('input', join(f.directory, 'build/link'));
|
||||
keys.add(f.key());
|
||||
rmSync(join(f.directory, 'build/link'));
|
||||
symlinkSync('Dockerfile', join(f.directory, 'build/link'));
|
||||
keys.add(f.key());
|
||||
assert.equal(keys.size, 8);
|
||||
mkdirSync(join(f.directory, 'pkgbuilds/example'), { recursive: true });
|
||||
const key = f.key();
|
||||
writeFileSync(join(f.directory, 'pkgbuilds/example/PKGBUILD'), 'pkgver=2\n');
|
||||
assert.equal(f.key(), key, 'package changes must not invalidate the build environment');
|
||||
});
|
||||
|
||||
test('fresh builds refresh package layers and record their compatibility key', t => {
|
||||
const f = fixture(t);
|
||||
const key = f.key('--arch', 'aarch64');
|
||||
const result = f.run(['build', '--arch', 'aarch64', '--tag', 'candidate:test', '--fresh']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const build = f.calls().find(args => args[0] === 'buildx');
|
||||
assert.ok(build.includes('--no-cache'));
|
||||
assert.ok(build.includes('--pull'));
|
||||
assert.ok(build.includes('--load'));
|
||||
assert.ok(build.includes('--platform=linux/arm64'));
|
||||
assert.ok(build.includes(`org.omarchy.builder.key=${key}`));
|
||||
assert.ok(build.includes('candidate:test'));
|
||||
});
|
||||
|
||||
test('invalid targets fail before starting an image build', t => {
|
||||
const f = fixture(t);
|
||||
for (const args of [['key', '--arch', 'invalid'], ['build', '--mirror', 'invalid'], ['key', '--fresh']]) {
|
||||
assert.notEqual(f.run(args).status, 0);
|
||||
}
|
||||
});
|
||||
|
||||
function publish(f, extraEnv = {}) {
|
||||
const script = workflow.split(' - name: Publish tested image\n')[1].split(' run: |\n')[1]
|
||||
.replaceAll('${{ matrix.arch }}', 'x86_64');
|
||||
return spawnSync('bash', ['-e', '-o', 'pipefail', '-c', script], {
|
||||
cwd: f.directory, encoding: 'utf8', env: {
|
||||
...f.env, REGISTRY_IMAGE: 'ghcr.io/omacom/omarchy-pkg-builder', CANDIDATE_IMAGE: 'candidate:test',
|
||||
GH_TOKEN: 'fixture', GH_ACTOR: 'fixture', DOCKER_CONFIG: join(f.directory, 'auth'),
|
||||
RUNNER_TEMP: f.directory, GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '1',
|
||||
GITHUB_STEP_SUMMARY: join(f.directory, 'summary'), ...extraEnv,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('a public tested image gets a version tag before the compatible-image tag advances', t => {
|
||||
const f = fixture(t);
|
||||
const key = f.key();
|
||||
const result = publish(f);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const calls = f.calls();
|
||||
assert.deepEqual(calls.filter(args => args[0] === 'push').map(args => args[1]), [
|
||||
`ghcr.io/omacom/omarchy-pkg-builder:${key}-123-1`, `ghcr.io/omacom/omarchy-pkg-builder:${key}`,
|
||||
]);
|
||||
assert.ok(calls.findIndex(args => args[0] === 'manifest') < calls.findLastIndex(args => args[0] === 'push'));
|
||||
});
|
||||
|
||||
test('a private image or failed push never replaces the previous compatible-image tag', t => {
|
||||
for (const extraEnv of [{ PRIVATE_IMAGE: '1' }, { PUSH_FAIL: '1' }]) {
|
||||
const f = fixture(t);
|
||||
const key = f.key();
|
||||
const result = publish(f, extraEnv);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(f.calls().some(args => args[0] === 'push' && args[1] === `ghcr.io/omacom/omarchy-pkg-builder:${key}`), false);
|
||||
}
|
||||
});
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# Self-test for ci/controller.sh: every decision, no cloud.
|
||||
#
|
||||
# The controller's two API functions are overridden with canned responses and
|
||||
# a recorder, then each scenario asserts which creates and deletes it issued.
|
||||
set -euo pipefail
|
||||
ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
||||
|
||||
export REPO=o/r DIGITALOCEAN_TOKEN=x GITHUB_TOKEN=x
|
||||
export CLOUD_INIT="$ROOT/ci/runner-cloud-init.yaml" LOCK=/tmp/controller-test.lock
|
||||
CONTROLLER_LIBRARY_ONLY=1 source "$ROOT/ci/controller.sh"
|
||||
|
||||
# Calls are recorded to a file: the controller invokes the API functions
|
||||
# inside command substitutions, and a subshell cannot append to an array.
|
||||
CALLS_FILE=$(mktemp); trap 'rm -f "$CALLS_FILE"' EXIT
|
||||
NOW=$(date -u +%FT%TZ)
|
||||
OLD=$(date -u -d '5 hours ago' +%FT%TZ)
|
||||
|
||||
# Scenario state: DROPLETS is "id status created" lines, QUEUED a count,
|
||||
# BUSY a count.
|
||||
do_api() {
|
||||
local path=$1; shift
|
||||
echo "do $path $*" >>"$CALLS_FILE"
|
||||
case "$path" in
|
||||
droplets\?*) printf '%s\n' "$DROPLETS" | jq -Rs '{droplets: [split("\n")[] | select(length>0) | split(" ") | {id: .[0]|tonumber, status: .[1], created_at: .[2]}]}' ;;
|
||||
droplets) echo '{"droplet":{"id":999}}' ;;
|
||||
droplets/*) echo '{}' ;;
|
||||
esac
|
||||
}
|
||||
gh_api() {
|
||||
local path=$1; shift
|
||||
echo "gh $path $*" >>"$CALLS_FILE"
|
||||
case "$path" in
|
||||
*/actions/runs\?*) jq -nc --argjson n "$QUEUED" '{workflow_runs: [range($n) | {id: .}]}' ;;
|
||||
*/actions/runs/*/jobs) echo '{"jobs":[{"id":1,"status":"queued","labels":["self-hosted","omarchy-builder"]}]}' ;;
|
||||
*/actions/runners\?*) jq -nc --argjson n "$BUSY" '{runners: [range($n) | {busy: true, labels: [{name: "omarchy-builder"}]}]}' ;;
|
||||
*/registration-token) echo '{"token":"T"}' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
creates() { grep -c '^do droplets -X POST' "$CALLS_FILE" || true; }
|
||||
deletes() { grep -c '^do droplets/.* -X DELETE' "$CALLS_FILE" || true; }
|
||||
run() { : >"$CALLS_FILE"; controller_tick >/dev/null; }
|
||||
check() { # check <name> <expected creates> <expected deletes>
|
||||
local c d; c=$(creates); d=$(deletes)
|
||||
if [[ "$c" == "$2" && "$d" == "$3" ]]; then echo "PASS: $1"; else echo "FAIL: $1 (creates=$c want $2, deletes=$d want $3)"; cat "$CALLS_FILE"; exit 1; fi
|
||||
}
|
||||
|
||||
DROPLETS="" QUEUED=0 BUSY=0; run; check "idle: nothing queued, nothing to reap" 0 0
|
||||
DROPLETS="" QUEUED=2 BUSY=0; run; check "two queued, none live: create two" 2 0
|
||||
DROPLETS="1 active $NOW" QUEUED=1 BUSY=1; run; check "one queued, one live but busy: create one" 1 0
|
||||
DROPLETS="1 active $NOW" QUEUED=1 BUSY=0; run; check "one queued, one live and idle: it will take it" 0 0
|
||||
DROPLETS="1 off $NOW" QUEUED=0 BUSY=0; run; check "powered-off droplet reaped" 0 1
|
||||
DROPLETS="1 active $OLD" QUEUED=0 BUSY=0; run; check "over-age droplet reaped even if active" 0 1
|
||||
DROPLETS=$'1 active '"$NOW"$'\n2 active '"$NOW"$'\n3 active '"$NOW"$'\n4 active '"$NOW" QUEUED=3 BUSY=4; MAX_DROPLETS=4; run; check "at cap: no creates" 0 0
|
||||
DROPLETS=$'1 active '"$NOW"$'\n2 active '"$NOW" QUEUED=5 BUSY=2; MAX_DROPLETS=3; run; check "cap limits creates to remaining room" 1 0
|
||||
DROPLETS="1 off $NOW" QUEUED=1 BUSY=0; MAX_DROPLETS=4; run; check "off droplet is not capacity: reaped and replaced" 1 1
|
||||
|
||||
# The create body must carry the tag (reaper scope) and substituted user-data.
|
||||
BODY_FILE=$(mktemp); trap 'rm -f "$CALLS_FILE" "$BODY_FILE"' EXIT
|
||||
do_api() { if [[ $1 == droplets ]]; then printf '%s' "${*: -1}" >"$BODY_FILE"; echo '{"droplet":{"id":1}}'; else echo '{"droplets":[]}'; fi; }
|
||||
gh_api() { echo '{"token":"TOK"}'; }
|
||||
create_droplet >/dev/null
|
||||
jq -e '.tags == ["omarchy-builder"] and .size == "g5-32vcpu-64gb-50gb" and (.user_data | test("--token \"TOK\"")) and (.user_data | test("__") | not)' "$BODY_FILE" >/dev/null \
|
||||
&& echo "PASS: create body carries tag, size, substituted user-data" \
|
||||
|| { echo "FAIL: create body"; jq . "$BODY_FILE" | head -20; exit 1; }
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
||||
INSTALL_SCRIPT="$REPO_ROOT/pkgbuilds/dell-xps-touchpad-haptics/dell-xps-touchpad-haptics.install"
|
||||
TEST_ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TEST_ROOT"' EXIT
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$INSTALL_SCRIPT"
|
||||
|
||||
home="$TEST_ROOT/home"
|
||||
config_dir="$home/.config/omarchy"
|
||||
config_path="$config_dir/dell-haptic.conf"
|
||||
protected_file="$TEST_ROOT/protected"
|
||||
runuser_call="$TEST_ROOT/runuser-call"
|
||||
chown_call="$TEST_ROOT/chown-call"
|
||||
runuser_stub="$TEST_ROOT/runuser"
|
||||
mkdir -p "$config_dir"
|
||||
printf 'must remain unchanged\n' >"$protected_file"
|
||||
ln -s "$protected_file" "$config_path"
|
||||
|
||||
chown() {
|
||||
printf '%s\n' "$*" >>"$chown_call"
|
||||
}
|
||||
|
||||
_ensure_user_config test-user "$home"
|
||||
[[ ! -e $chown_call ]]
|
||||
[[ ! -e $runuser_call ]]
|
||||
[[ $(cat "$protected_file") == 'must remain unchanged' ]]
|
||||
|
||||
rm "$config_path"
|
||||
printf '%s\n' \
|
||||
'#!/bin/bash' \
|
||||
'set -euo pipefail' \
|
||||
'[[ $1 == --user && $2 == test-user && $3 == -- && $4 == /usr/bin/env ]]' \
|
||||
'[[ $5 == "HOME=$EXPECTED_HOME" && $6 == USER=test-user && $7 == LOGNAME=test-user ]]' \
|
||||
'[[ $8 == /usr/bin/dell-xps-touchpad-haptics && $9 == set && ${10} == high ]]' \
|
||||
'printf "%s\n" "$*" >>"$RUNUSER_CALL"' \
|
||||
'printf "INTENSITY=100\n" >"$EXPECTED_CONFIG"' >"$runuser_stub"
|
||||
chmod +x "$runuser_stub"
|
||||
export EXPECTED_HOME="$home"
|
||||
export EXPECTED_CONFIG="$config_path"
|
||||
export RUNUSER_CALL="$runuser_call"
|
||||
_runuser_path="$runuser_stub"
|
||||
|
||||
_ensure_user_config test-user "$home"
|
||||
[[ ! -e $chown_call ]]
|
||||
[[ -f $config_path && ! -L $config_path ]]
|
||||
[[ $(cat "$config_path") == 'INTENSITY=100' ]]
|
||||
grep -q '^--user test-user -- /usr/bin/env ' "$runuser_call"
|
||||
|
||||
echo 'PASS: user config creation drops privileges and never chowns symlink targets'
|
||||
Executable
+532
@@ -0,0 +1,532 @@
|
||||
#!/bin/bash
|
||||
# limine-mkinitcpio-hook: the x86_64 package is upstream's as before. On
|
||||
# aarch64, limine-apple-gate leaves every machine but an Apple Silicon Mac as
|
||||
# the x86_64 package behaves; a Mac keeps /boot current through mkinitcpio's
|
||||
# own kernel hook, and Limine waits until Omarchy activates it.
|
||||
#
|
||||
# The package() of the PKGBUILD runs over an upstream-shaped source tree
|
||||
# (LIMINE_ENTRY_TOOL_SRC may name a real limine-entry-tool checkout instead),
|
||||
# and pacman transactions replay through the packaged hooks on fixture machines.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
||||
RECIPE=$REPO_ROOT/pkgbuilds/limine-mkinitcpio-hook
|
||||
GATE=$RECIPE/limine-apple-gate
|
||||
TEST_ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TEST_ROOT"' EXIT
|
||||
|
||||
fail() {
|
||||
echo "not ok - $1" >&2
|
||||
[[ $# -lt 2 ]] || printf '%s\n' "$2" >&2
|
||||
exit 1
|
||||
}
|
||||
pass() {
|
||||
echo "ok - $1"
|
||||
}
|
||||
|
||||
# pacman hands NeedsTargets to a hook over a socket, and a hook that stops
|
||||
# reading early fails the write ("unable to write to pipe").
|
||||
SOCKET_FEED='
|
||||
import socket, subprocess, sys
|
||||
parent, child = socket.socketpair()
|
||||
hook = subprocess.Popen(sys.argv[1:], stdin=child)
|
||||
child.close()
|
||||
try:
|
||||
parent.sendall(sys.stdin.buffer.read())
|
||||
parent.shutdown(socket.SHUT_WR)
|
||||
except OSError as error:
|
||||
print("unable to write to pipe (%s)" % error.strerror, file=sys.stderr)
|
||||
hook.wait()
|
||||
sys.exit(141)
|
||||
sys.exit(hook.wait())
|
||||
'
|
||||
socket_feed() { python3 -c "$SOCKET_FEED" "$@"; }
|
||||
|
||||
apple_tree() { printf 'apple,j316s\0apple,t6000\0apple,arm-platform\0'; }
|
||||
live_is_mac() {
|
||||
local source
|
||||
for source in /proc/device-tree/compatible /sys/firmware/devicetree/base/compatible; do
|
||||
[[ -r $source ]] && tr '\0' '\n' <"$source" | grep -q '^apple,' && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# The gate trusts fixtures only from an unprivileged caller: as root it reads
|
||||
# the live machine. Check that, then carry on as nobody.
|
||||
if ((EUID == 0)); then
|
||||
if ! live_is_mac; then
|
||||
mkdir -p "$TEST_ROOT/root-proc/device-tree"
|
||||
apple_tree >"$TEST_ROOT/root-proc/device-tree/compatible"
|
||||
out=$(OMARCHY_PROC_ROOT=$TEST_ROOT/root-proc bash "$GATE" echo ran </dev/null)
|
||||
[[ $out == ran ]] || fail "as root the gate ignores a fixture device tree" "$out"
|
||||
pass "as root the gate reads the live machine, not fixtures"
|
||||
fi
|
||||
rm -rf "$TEST_ROOT"
|
||||
exec setpriv --reuid=nobody --regid=nobody --clear-groups -- bash "$REPO_ROOT/tests/limine-mkinitcpio-hook.sh"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sources: the local aarch64 sources are pinned, x86_64 has none.
|
||||
bash -n "$RECIPE/PKGBUILD"
|
||||
bash -n "$GATE"
|
||||
mapfile -t sources < <(CARCH=aarch64 bash -c 'source "$1"; printf "%s\n" "${source_aarch64[@]}"' _ "$RECIPE/PKGBUILD")
|
||||
mapfile -t sums < <(CARCH=aarch64 bash -c 'source "$1"; printf "%s\n" "${sha256sums_aarch64[@]}"' _ "$RECIPE/PKGBUILD")
|
||||
((${#sources[@]} == ${#sums[@]})) || fail "every aarch64 source has a checksum"
|
||||
local_sources=0
|
||||
for i in "${!sources[@]}"; do
|
||||
[[ ${sources[i]} == *://* ]] && continue
|
||||
((++local_sources))
|
||||
[[ $(sha256sum "$RECIPE/${sources[i]}" | cut -d' ' -f1) == "${sums[i]}" ]] ||
|
||||
fail "the PKGBUILD pins ${sources[i]} by its sha256"
|
||||
done
|
||||
((local_sources == 2)) || fail "limine-apple-gate and mkinitcpio-install.hook are the aarch64 local sources"
|
||||
x86_sources=$(CARCH=x86_64 bash -c 'source "$1"; printf "%s\n" "${source[@]}" "${source_x86_64[@]}"' _ "$RECIPE/PKGBUILD")
|
||||
[[ $x86_sources != *limine-apple-gate* && $x86_sources != *mkinitcpio-install.hook* ]] ||
|
||||
fail "x86_64 builds take no Apple Silicon source"
|
||||
grep -Fxq 'Exec = /usr/share/libalpm/scripts/mkinitcpio install' "$RECIPE/mkinitcpio-install.hook" ||
|
||||
fail "mkinitcpio-install.hook is mkinitcpio's own kernel hook"
|
||||
pass "the Apple Silicon sources are aarch64-only and pinned by checksum"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# package() for both architectures over the same upstream tree.
|
||||
SRC=$TEST_ROOT/src
|
||||
upstream=$SRC/limine-entry-tool
|
||||
mkdir -p "$upstream/build/native/nativeCompile"
|
||||
if [[ -n ${LIMINE_ENTRY_TOOL_SRC:-} ]]; then
|
||||
cp -a "$LIMINE_ENTRY_TOOL_SRC/install" "$LIMINE_ENTRY_TOOL_SRC/README.md" "$LIMINE_ENTRY_TOOL_SRC/CHANGELOG.md" "$upstream/"
|
||||
else
|
||||
tool=$upstream/install/arch-linux/limine-entry-tool
|
||||
hook=$upstream/install/arch-linux/limine-mkinitcpio-hook
|
||||
mkdir -p "$tool/etc/boot/hooks/pre.d" "$tool/usr/bin" "$tool/usr/lib/limine" "$tool/usr/share/libalpm/hooks" \
|
||||
"$hook/etc/pacman.d/hooks" "$hook/usr/bin" "$hook/usr/local/bin" "$hook/usr/share/libalpm/hooks" "$hook/usr/share/libalpm/scripts"
|
||||
echo '# README' >"$upstream/README.md"
|
||||
echo '# CHANGELOG' >"$upstream/CHANGELOG.md"
|
||||
echo 'ESP_PATH=""' >"$tool/etc/limine-entry-tool.conf"
|
||||
echo 'ESP_PATH=""' >"$hook/etc/limine-entry-tool.conf"
|
||||
for name in limine-install limine-entry-tool limine-reset-enroll limine-enroll-config; do
|
||||
printf '#!/usr/bin/env bash\n' >"$tool/usr/bin/$name"
|
||||
done
|
||||
printf '#!/usr/bin/env bash\n' >"$tool/usr/lib/limine/limine-common-functions"
|
||||
for name in limine-mkinitcpio limine-update; do
|
||||
printf '#!/usr/bin/env bash\n' >"$hook/usr/bin/$name"
|
||||
done
|
||||
for name in limine-mkinitcpio-install limine-mkinitcpio-remove; do
|
||||
printf '#!/usr/bin/env bash\n' >"$hook/usr/share/libalpm/scripts/$name"
|
||||
done
|
||||
cat >"$tool/usr/share/libalpm/hooks/80-limine-efi-deploy.hook" <<'HOOK'
|
||||
[Trigger]
|
||||
Operation = Install
|
||||
Operation = Upgrade
|
||||
Type = Package
|
||||
Target = limine
|
||||
Target = limine-git
|
||||
Target = limine-dev
|
||||
|
||||
[Action]
|
||||
Description = Deploying Limine after upgrade...
|
||||
When = PostTransaction
|
||||
Exec = /usr/bin/limine-install --no-efi-register
|
||||
HOOK
|
||||
cat >"$hook/usr/share/libalpm/hooks/80-limine-efi-deploy.hook" <<'HOOK'
|
||||
[Trigger]
|
||||
Operation = Install
|
||||
Operation = Upgrade
|
||||
Type = Package
|
||||
Target = limine
|
||||
Target = limine-git
|
||||
Target = limine-dev
|
||||
Target = limine-mkinitcpio-hook
|
||||
Target = limine-mkinitcpio-hook-git
|
||||
|
||||
[Action]
|
||||
Description = Deploying Limine after upgrade...
|
||||
When = PostTransaction
|
||||
Exec = /usr/bin/limine-install
|
||||
HOOK
|
||||
cat >"$hook/usr/share/libalpm/hooks/60-limine-mkinitcpio-remove-pre.hook" <<'HOOK'
|
||||
[Trigger]
|
||||
Type = Path
|
||||
Operation = Remove
|
||||
Target = usr/lib/modules/*/modules.builtin
|
||||
|
||||
[Action]
|
||||
Description = Record kernels marked for removal in Limine
|
||||
When = PreTransaction
|
||||
Exec = /usr/share/libalpm/scripts/limine-mkinitcpio-remove pre
|
||||
NeedsTargets
|
||||
HOOK
|
||||
cat >"$hook/usr/share/libalpm/hooks/90-limine-mkinitcpio-remove-post.hook" <<'HOOK'
|
||||
[Trigger]
|
||||
Type = Path
|
||||
Operation = Remove
|
||||
Target = usr/lib/modules/*/modules.builtin
|
||||
|
||||
[Action]
|
||||
Description = Clean Limine boot entries of removed kernels
|
||||
When = PostTransaction
|
||||
Exec = /usr/share/libalpm/scripts/limine-mkinitcpio-remove post
|
||||
HOOK
|
||||
cat >"$hook/etc/pacman.d/hooks/90-mkinitcpio-install.hook" <<'HOOK'
|
||||
[Trigger]
|
||||
Type = Path
|
||||
Operation = Install
|
||||
Operation = Upgrade
|
||||
Operation = Remove
|
||||
Target = usr/lib/initcpio/*
|
||||
Target = usr/lib/firmware/*
|
||||
Target = usr/lib/modules/*/extramodules/
|
||||
Target = usr/src/*/dkms.conf
|
||||
Target = usr/lib/systemd/systemd
|
||||
Target = usr/bin/cryptsetup
|
||||
Target = usr/bin/lvm
|
||||
|
||||
[Trigger]
|
||||
Type = Path
|
||||
Operation = Install
|
||||
Operation = Upgrade
|
||||
Target = usr/lib/modules/*/modules.builtin
|
||||
|
||||
[Trigger]
|
||||
Type = Package
|
||||
Operation = Install
|
||||
Operation = Upgrade
|
||||
Target = mkinitcpio
|
||||
Target = mkinitcpio-git
|
||||
|
||||
[Action]
|
||||
Description = Updating linux initcpios...
|
||||
When = PostTransaction
|
||||
Exec = /usr/share/libalpm/scripts/limine-mkinitcpio-install
|
||||
NeedsTargets
|
||||
HOOK
|
||||
cat >"$hook/usr/local/bin/mkinitcpio" <<'WRAPPER'
|
||||
#!/usr/bin/env bash
|
||||
|
||||
/usr/bin/mkinitcpio "$@"
|
||||
|
||||
_color_reset=""
|
||||
_color_yellow=""
|
||||
colors="$(tput colors 2>/dev/null || echo 0)"
|
||||
if ((colors >= 8)); then
|
||||
_color_reset="\033[0m"
|
||||
_color_yellow="\033[1;33m"
|
||||
fi
|
||||
|
||||
if [[ " $* " == *" -P "* || " $* " == *" --allpresets "* || " $* " == *" -p "* || " $* " == *" --preset "* ]]; then
|
||||
printf '%b==> WARNING: This does not update Limine boot entries.%b\n' "${_color_yellow}" "${_color_reset}" >&2
|
||||
printf "%b Use 'limine-mkinitcpio' or 'limine-update' instead.%b\n" "${_color_yellow}" "${_color_reset}" >&2
|
||||
|
||||
read -rp "==> Would you like to run 'limine-mkinitcpio' now? [Y/n]: " answer
|
||||
case "${answer,,}" in
|
||||
"" | "y" | "yes")
|
||||
/usr/bin/limine-mkinitcpio
|
||||
;;
|
||||
*)
|
||||
# nothing
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
WRAPPER
|
||||
fi
|
||||
printf 'native image\n' >"$upstream/build/native/nativeCompile/limine-entry-tool"
|
||||
chmod -R u+w,go+rX "$upstream"
|
||||
find "$upstream/install" -path '*/bin/*' -type f -exec chmod 755 {} + -o -path '*/scripts/*' -type f -exec chmod 755 {} +
|
||||
ln -s "$RECIPE/limine-apple-gate" "$RECIPE/mkinitcpio-install.hook" "$SRC/"
|
||||
|
||||
# makepkg runs package() with errexit.
|
||||
build_package() {
|
||||
local arch=$1 out=$2
|
||||
mkdir -p "$out"
|
||||
CARCH=$arch srcdir=$SRC pkgdir=$out bash -euo pipefail -c 'source "$1"; package' _ "$RECIPE/PKGBUILD" ||
|
||||
fail "package() succeeds for $arch"
|
||||
}
|
||||
PKG_X86=$TEST_ROOT/pkg-x86_64
|
||||
PKG_ARM=$TEST_ROOT/pkg-aarch64
|
||||
build_package x86_64 "$PKG_X86"
|
||||
build_package aarch64 "$PKG_ARM"
|
||||
|
||||
manifest() {
|
||||
(cd "$1" && find . \( -type f -o -type l \) -printf '%p %m %l\n' | LC_ALL=C sort) |
|
||||
while read -r path mode target; do
|
||||
if [[ -n $target ]]; then
|
||||
echo "$path $mode -> $target"
|
||||
else
|
||||
echo "$path $mode $(sha256sum "$1/$path" | cut -d' ' -f1)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
scripts=/usr/share/libalpm/scripts
|
||||
gate=$scripts/limine-apple-gate
|
||||
upstream_hook=$upstream/install/arch-linux/limine-mkinitcpio-hook
|
||||
for file in etc/pacman.d/hooks/90-mkinitcpio-install.hook usr/local/bin/mkinitcpio \
|
||||
usr/share/libalpm/hooks/{60-limine-mkinitcpio-remove-pre,80-limine-efi-deploy,90-limine-mkinitcpio-remove-post}.hook; do
|
||||
cmp -s "$upstream_hook/$file" "$PKG_X86/$file" || fail "x86_64 ships upstream's $file unchanged"
|
||||
done
|
||||
[[ ! -e $PKG_X86$gate && ! -e $PKG_X86/usr/share/libalpm/hooks/90-mkinitcpio-apple-install.hook ]] ||
|
||||
fail "x86_64 ships no Apple Silicon file"
|
||||
pass "x86_64 packages upstream's hooks and wrapper unchanged"
|
||||
|
||||
differences=$(diff <(manifest "$PKG_X86") <(manifest "$PKG_ARM") || true)
|
||||
changed=$(sed -n 's/^> \(\S*\) .*/\1/p' <<<"$differences")
|
||||
expected=$(printf '%s\n' ./etc/pacman.d/hooks/90-mkinitcpio-install.hook \
|
||||
./usr/local/bin/mkinitcpio \
|
||||
./usr/share/libalpm/hooks/60-limine-mkinitcpio-remove-pre.hook \
|
||||
./usr/share/libalpm/hooks/80-limine-efi-deploy.hook \
|
||||
./usr/share/libalpm/hooks/90-limine-mkinitcpio-remove-post.hook \
|
||||
./usr/share/libalpm/hooks/90-mkinitcpio-apple-install.hook \
|
||||
./usr/share/libalpm/scripts/limine-apple-gate)
|
||||
[[ $changed == "$expected" ]] || fail "aarch64 differs from x86_64 only in the gated hooks, the wrapper and the gate" "$changed"
|
||||
[[ -z $(sed -n 's/^< \(\S*\) .*/\1/p' <<<"$differences" | grep -vxF "$expected") ]] ||
|
||||
fail "aarch64 keeps every x86_64 file"
|
||||
[[ $(stat -c %a "$PKG_ARM$gate") == 755 ]] || fail "the gate is executable"
|
||||
diff <(grep -v '^Exec = \|^Description = ' "$RECIPE/mkinitcpio-install.hook") \
|
||||
<(grep -v '^Exec = \|^Description = ' "$PKG_ARM/usr/share/libalpm/hooks/90-mkinitcpio-apple-install.hook") >/dev/null ||
|
||||
fail "the Mac's kernel hook keeps mkinitcpio's triggers"
|
||||
[[ $(sed -n 2p "$PKG_ARM/usr/local/bin/mkinitcpio") == "$gate --mac && exec /usr/bin/mkinitcpio \"\$@\"" ]] ||
|
||||
fail "the aarch64 wrapper is plain mkinitcpio on a Mac"
|
||||
pass "aarch64 routes Limine's hooks and the wrapper through the gate and adds the Mac's kernel hook"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture machines, read through OMARCHY_PROC_ROOT, OMARCHY_LIMINE_GATE and
|
||||
# OMARCHY_LIMINE_DEFAULT.
|
||||
machine() {
|
||||
local name=$1 compatible=$2 dir=$TEST_ROOT/machines/$1
|
||||
mkdir -p "$dir/proc" "$dir/state"
|
||||
if [[ -n $compatible ]]; then
|
||||
mkdir -p "$dir/proc/device-tree"
|
||||
printf "$compatible" >"$dir/proc/device-tree/compatible"
|
||||
fi
|
||||
}
|
||||
machine x86 ''
|
||||
machine generic-aarch64 'linux,dummy-virt\0'
|
||||
machine qualcomm 'lenovo,thinkpad-t14s\0qcom,x1e78100\0qcom,x1e80100\0'
|
||||
machine mac-dormant 'apple,j316s\0apple,t6000\0apple,arm-platform\0'
|
||||
machine mac-marker-only 'apple,j316s\0apple,t6000\0apple,arm-platform\0'
|
||||
machine mac-active 'apple,j316s\0apple,t6000\0apple,arm-platform\0'
|
||||
: >"$TEST_ROOT/machines/mac-marker-only/state/limine.enabled"
|
||||
: >"$TEST_ROOT/machines/mac-active/state/limine.enabled"
|
||||
echo 'ESP_PATH="/boot/efi"' >"$TEST_ROOT/machines/mac-active/state/limine"
|
||||
|
||||
on() {
|
||||
local dir=$TEST_ROOT/machines/$1
|
||||
shift
|
||||
(
|
||||
export OMARCHY_PROC_ROOT=$dir/proc OMARCHY_LIMINE_GATE=$dir/state/limine.enabled OMARCHY_LIMINE_DEFAULT=$dir/state/limine
|
||||
"$@"
|
||||
)
|
||||
}
|
||||
status() {
|
||||
local rc=0
|
||||
"$@" || rc=$?
|
||||
echo "$rc"
|
||||
}
|
||||
|
||||
for m in x86 generic-aarch64 qualcomm; do
|
||||
[[ $(on "$m" status bash "$GATE" --mac) == 1 ]] || fail "$m is not a Mac"
|
||||
[[ $(printf 'a\nb\n' | on "$m" bash "$GATE" cat) == $'a\nb' ]] || fail "$m runs Limine's hooks with their targets"
|
||||
[[ $(on "$m" status bash "$GATE" bash -c 'exit 7' </dev/null) == 7 ]] || fail "$m keeps the hook's status"
|
||||
[[ -z $(printf 'a\n' | on "$m" bash "$GATE" --mac cat 2>&1) ]] || fail "$m skips the Mac's hooks quietly"
|
||||
[[ $(on "$m" bash "$GATE" --not-mac echo ran </dev/null) == ran ]] || fail "$m deploys Limine's EFI binary"
|
||||
done
|
||||
for m in mac-dormant mac-marker-only mac-active; do
|
||||
[[ $(on "$m" status bash "$GATE" --mac) == 0 ]] || fail "$m is a Mac"
|
||||
[[ $(printf 'a\n' | on "$m" bash "$GATE" --mac cat) == a ]] || fail "$m runs the Mac's hooks with their targets"
|
||||
[[ -z $(printf 'a\n' | on "$m" bash "$GATE" --not-mac cat 2>&1) ]] || fail "$m leaves Limine's EFI binary to omarchy-mac-boot"
|
||||
done
|
||||
for m in mac-dormant mac-marker-only; do
|
||||
out=$(printf 'usr/lib/modules/7.1.0/modules.builtin\n' | on "$m" bash "$GATE" bash -c 'echo ran; exit 100' 2>&1) ||
|
||||
fail "$m passes over Limine's hooks successfully"
|
||||
[[ -z $out ]] || fail "$m passes over Limine's hooks quietly" "$out"
|
||||
[[ -z $(on "$m" bash "$GATE" echo ran <&- 2>&1) ]] || fail "$m passes over a hook without targets"
|
||||
done
|
||||
# A linux-firmware upgrade streams thousands of targets, more than a socket holds.
|
||||
many_targets() { printf 'usr/lib/firmware/fixture/%s.bin\n' $(seq 60000); }
|
||||
many_targets | on mac-dormant socket_feed bash "$GATE" true || fail "a dormant Mac reads every target of a hook it passes over"
|
||||
many_targets | on x86 socket_feed bash "$GATE" --mac true || fail "other machines read every target of the Mac's hooks"
|
||||
many_targets | on mac-active socket_feed bash "$GATE" --not-mac true || fail "a Mac reads every target of the deploy hook"
|
||||
many_targets | on mac-dormant bash "$GATE" true || fail "a dormant Mac reads every target piped to a hook it passes over"
|
||||
[[ $(printf 'a\n' | on mac-active bash "$GATE" bash -c 'echo "ran $1"; cat' _ post) == $'ran post\na' ]] ||
|
||||
fail "an active Mac runs Limine's hooks with their arguments and targets"
|
||||
[[ $(on mac-active status bash "$GATE" bash -c 'exit 7' </dev/null) == 7 ]] || fail "an active Mac keeps the hook's status"
|
||||
[[ $(on x86 status bash "$GATE" </dev/null 2>/dev/null) == 2 ]] || fail "the gate needs a command"
|
||||
pass "the gate runs Limine's hooks everywhere but a Mac that has not activated Limine"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pacman: hooks from /usr/share/libalpm/hooks, overridden by name from
|
||||
# /etc/pacman.d/hooks, run in name order, each with the targets of the
|
||||
# transaction its triggers match (sorted, over a socket for NeedsTargets,
|
||||
# else no stdin).
|
||||
# Transactions are lines of "<Operation> <Path|Package> <target>".
|
||||
fake_root() {
|
||||
local pkg=$1 root=$2 name
|
||||
cp -a "$pkg" "$root"
|
||||
cp "$RECIPE/mkinitcpio-install.hook" "$root/usr/share/libalpm/hooks/90-mkinitcpio-install.hook"
|
||||
for name in usr/share/libalpm/scripts/limine-mkinitcpio-install usr/share/libalpm/scripts/limine-mkinitcpio-remove \
|
||||
usr/bin/limine-install usr/share/libalpm/scripts/mkinitcpio usr/bin/mkinitcpio usr/bin/limine-mkinitcpio; do
|
||||
rm -f "$root/$name"
|
||||
printf '#!/bin/bash\n{ printf %%s %q; if (($#)); then printf " %%s" "$@"; fi; echo; if [[ -S /dev/stdin || -p /dev/stdin ]]; then sed "s/^/ /"; fi; } >>"$SIM_LOG"\n' \
|
||||
"${name##*/}" >"$root/$name"
|
||||
chmod 755 "$root/$name"
|
||||
done
|
||||
}
|
||||
|
||||
run_hook() {
|
||||
local root=$1 hook=$2 when=$3 transaction=$4
|
||||
local line section='' hook_when='' exec_line='' needs=0 type='' ops='' targets=''
|
||||
local -a triggers=()
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
case $line in
|
||||
'[Trigger]' | '[Action]')
|
||||
[[ $section != Trigger ]] || triggers+=("$type|$ops|$targets")
|
||||
section=${line//[][]/} type='' ops=' ' targets=''
|
||||
;;
|
||||
NeedsTargets) needs=1 ;;
|
||||
*' = '*)
|
||||
case $section/${line%% = *} in
|
||||
Trigger/Type) type=${line#* = } ;;
|
||||
Trigger/Operation) ops+="${line#* = } " ;;
|
||||
Trigger/Target) targets+="${line#* = } " ;;
|
||||
Action/When) hook_when=${line#* = } ;;
|
||||
Action/Exec) exec_line=${line#* = } ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
done <"$hook"
|
||||
[[ $hook_when == "$when" ]] || return 0
|
||||
|
||||
local op kind target trigger t_type t_ops t_targets pattern
|
||||
local -a matched=() patterns=()
|
||||
while read -r op kind target; do
|
||||
for trigger in "${triggers[@]}"; do
|
||||
IFS='|' read -r t_type t_ops t_targets <<<"$trigger"
|
||||
[[ $t_type == "$kind" && $t_ops == *" $op "* ]] || continue
|
||||
read -ra patterns <<<"$t_targets"
|
||||
for pattern in "${patterns[@]}"; do
|
||||
# shellcheck disable=SC2053
|
||||
if [[ $target == $pattern ]]; then
|
||||
matched+=("$target")
|
||||
break 2
|
||||
fi
|
||||
done
|
||||
done
|
||||
done <"$transaction"
|
||||
((${#matched[@]})) || return 0
|
||||
|
||||
local word
|
||||
local -a argv=() command=()
|
||||
read -ra argv <<<"$exec_line"
|
||||
for word in "${argv[@]}"; do
|
||||
if [[ $word == /* && -e $root$word ]]; then command+=("$root$word"); else command+=("$word"); fi
|
||||
done
|
||||
if ((needs)); then
|
||||
printf '%s\n' "${matched[@]}" | LC_ALL=C sort -u | socket_feed "${command[@]}"
|
||||
else
|
||||
"${command[@]}" <&-
|
||||
fi
|
||||
}
|
||||
|
||||
run_transaction() {
|
||||
local root=$1 transaction=$2 when name
|
||||
local -A hooks=()
|
||||
for name in "$root"/usr/share/libalpm/hooks/*.hook "$root"/etc/pacman.d/hooks/*.hook; do
|
||||
[[ -e $name ]] && hooks[${name##*/}]=$name
|
||||
done
|
||||
for when in PreTransaction PostTransaction; do
|
||||
while read -r name; do
|
||||
run_hook "$root" "${hooks[$name.hook]}" "$when" "$transaction"
|
||||
done < <(printf '%s\n' "${!hooks[@]}" | sed 's/\.hook$//' | LC_ALL=C sort)
|
||||
done
|
||||
}
|
||||
|
||||
fake_root "$PKG_X86" "$TEST_ROOT/root-x86_64"
|
||||
fake_root "$PKG_ARM" "$TEST_ROOT/root-aarch64"
|
||||
|
||||
transaction() {
|
||||
local name=$1
|
||||
shift
|
||||
printf '%s\n' "$@" >"$TEST_ROOT/tx-$name"
|
||||
}
|
||||
transaction kernel \
|
||||
'Install Path usr/lib/modules/7.1.0-asahi/vmlinuz' \
|
||||
'Install Path usr/lib/modules/7.1.0-asahi/modules.builtin' \
|
||||
'Install Path usr/lib/modules/7.1.0-asahi/pkgbase' \
|
||||
'Remove Path usr/lib/modules/7.0.0-asahi/vmlinuz' \
|
||||
'Remove Path usr/lib/modules/7.0.0-asahi/modules.builtin' \
|
||||
'Remove Path usr/lib/modules/7.0.0-asahi/pkgbase' \
|
||||
'Upgrade Package linux-asahi'
|
||||
transaction firmware 'Upgrade Path usr/lib/firmware/brcm/fixture.bin' 'Upgrade Package linux-firmware'
|
||||
transaction modprobe 'Upgrade Path usr/lib/modprobe.d/' 'Install Path usr/lib/modprobe.d/omarchy-mac.conf' 'Upgrade Package omarchy-mac'
|
||||
transaction limine 'Upgrade Path usr/share/limine/BOOTAA64.EFI' 'Upgrade Package limine'
|
||||
|
||||
replay() {
|
||||
local arch=$1 m=$2 name=$3
|
||||
export SIM_LOG=$TEST_ROOT/log
|
||||
: >"$SIM_LOG"
|
||||
local out
|
||||
out=$(on "$m" run_transaction "$TEST_ROOT/root-$arch" "$TEST_ROOT/tx-$name" 2>&1) || fail "$name on $arch/$m replays"
|
||||
[[ -z $out ]] || fail "$name on $arch/$m prints nothing of the gate's" "$out"
|
||||
cat "$SIM_LOG"
|
||||
}
|
||||
|
||||
limine_kernel=$'limine-mkinitcpio-remove pre\n usr/lib/modules/7.0.0-asahi/modules.builtin\nlimine-mkinitcpio-remove post\nlimine-mkinitcpio-install\n usr/lib/modules/7.1.0-asahi/modules.builtin'
|
||||
stock_kernel=$'mkinitcpio install\n usr/lib/modules/7.1.0-asahi/vmlinuz'
|
||||
limine_firmware=$'limine-mkinitcpio-install\n usr/lib/firmware/brcm/fixture.bin'
|
||||
stock_firmware=$'mkinitcpio install\n usr/lib/firmware/brcm/fixture.bin'
|
||||
stock_modprobe=$'mkinitcpio install\n usr/lib/modprobe.d/'
|
||||
|
||||
declare -A want=(
|
||||
[kernel/other]=$limine_kernel
|
||||
[firmware/other]=$limine_firmware
|
||||
[modprobe/other]=''
|
||||
[limine/other]='limine-install'
|
||||
[kernel/mac-dormant]=$stock_kernel
|
||||
[firmware/mac-dormant]=$stock_firmware
|
||||
[modprobe/mac-dormant]=$stock_modprobe
|
||||
[limine/mac-dormant]=''
|
||||
[kernel/mac-active]=$'limine-mkinitcpio-remove pre\n usr/lib/modules/7.0.0-asahi/modules.builtin\nlimine-mkinitcpio-remove post\n'"$stock_kernel"$'\nlimine-mkinitcpio-install\n usr/lib/modules/7.1.0-asahi/modules.builtin'
|
||||
[firmware/mac-active]=$stock_firmware$'\n'$limine_firmware
|
||||
[modprobe/mac-active]=$stock_modprobe
|
||||
[limine/mac-active]=''
|
||||
)
|
||||
want[kernel/mac-marker-only]=${want[kernel/mac-dormant]}
|
||||
|
||||
for name in kernel firmware modprobe limine; do
|
||||
got=$(replay x86_64 x86 "$name")
|
||||
[[ $got == "${want[$name/other]}" ]] || fail "x86_64: $name transaction runs upstream's hooks" "$got"
|
||||
for m in generic-aarch64 qualcomm; do
|
||||
got=$(replay aarch64 "$m" "$name")
|
||||
[[ $got == "${want[$name/other]}" ]] || fail "aarch64 $m: $name transaction runs as on x86_64" "$got"
|
||||
done
|
||||
done
|
||||
pass "x86_64, generic aarch64 and Snapdragon transactions run Limine's hooks as upstream ships them"
|
||||
|
||||
for m in mac-dormant mac-marker-only mac-active; do
|
||||
for name in kernel firmware modprobe limine; do
|
||||
[[ -v want[$name/$m] ]] || continue
|
||||
got=$(replay aarch64 "$m" "$name")
|
||||
[[ $got == "${want[$name/$m]}" ]] || fail "$m: $name transaction" "$got"
|
||||
done
|
||||
done
|
||||
pass "a dormant Mac defers to mkinitcpio's own hook; an active Mac also writes Limine's entries after it"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The wrapper, as sudo mkinitcpio -P and mkinitcpio's hook script call it
|
||||
# (/usr/local/bin first in PATH, stdin at its end).
|
||||
wrapper() {
|
||||
local root=$1 m=$2
|
||||
sed "s|/usr/|$root/usr/|g" "$root/usr/local/bin/mkinitcpio" >"$TEST_ROOT/wrapper"
|
||||
export SIM_LOG=$TEST_ROOT/log
|
||||
: >"$SIM_LOG"
|
||||
on "$m" bash "$TEST_ROOT/wrapper" -P </dev/null >/dev/null 2>&1 || true
|
||||
cat "$SIM_LOG"
|
||||
}
|
||||
upstream_wrapper=$'mkinitcpio -P\nlimine-mkinitcpio'
|
||||
[[ $(wrapper "$TEST_ROOT/root-x86_64" x86) == "$upstream_wrapper" ]] || fail "x86_64 keeps upstream's wrapper"
|
||||
for m in generic-aarch64 qualcomm; do
|
||||
[[ $(wrapper "$TEST_ROOT/root-aarch64" "$m") == "$upstream_wrapper" ]] || fail "$m keeps upstream's wrapper"
|
||||
done
|
||||
for m in mac-dormant mac-active; do
|
||||
[[ $(wrapper "$TEST_ROOT/root-aarch64" "$m") == 'mkinitcpio -P' ]] || fail "$m runs plain mkinitcpio"
|
||||
done
|
||||
pass "the mkinitcpio wrapper is upstream's everywhere but a Mac, where it is plain mkinitcpio"
|
||||
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Removal regression fixtures. No real systemd manager, user home or package is touched."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# Characters that make systemd's shell_maybe_quote() quote a value, a copy of
|
||||
# SHELL_NEED_ESCAPE, GLOB_CHARS and the rest of SHELL_NEED_QUOTES in escape.h.
|
||||
SHELL_NEED_QUOTES = '"\\`$*?[]' + "'()<>|&;!"
|
||||
|
||||
|
||||
def systemd_environment_value(value):
|
||||
r"""Return VALUE as ``systemctl show-environment`` would print it.
|
||||
|
||||
print_variable() in systemctl-set-environment.c hands every value to
|
||||
shell_maybe_quote(SHELL_ESCAPE_POSIX) quotes special values and uses
|
||||
cescape_char() for control bytes.
|
||||
"""
|
||||
if not any(c in SHELL_NEED_QUOTES or c.isspace() or ord(c) < 0x20 or c == "\x7f"
|
||||
for c in value):
|
||||
return value
|
||||
escapes = dict(zip("\a\b\f\n\r\t\v\\'", (r"\a", r"\b", r"\f", r"\n", r"\r", r"\t", r"\v", r"\\", r"\'")))
|
||||
return "$'" + "".join(escapes.get(c, f"\\{ord(c):03o}" if ord(c) < 0x20 or c == "\x7f" else c)
|
||||
for c in value) + "'"
|
||||
|
||||
|
||||
class Removal(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="oma-removal-")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.home = self.root / "home"
|
||||
self.runtime = self.root / "runtime"
|
||||
self.units = self.home / ".config/systemd/user"
|
||||
self.units.mkdir(parents=True)
|
||||
self.runtime.mkdir()
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
self.log = self.root / "calls"
|
||||
self.env = dict(os.environ, PATH=f"{self.bin}:{os.environ['PATH']}", CALLS=str(self.log))
|
||||
self.executable("systemctl", '''#!/bin/bash
|
||||
printf '%s\\n' "$*" >> "$CALLS"
|
||||
case "$*" in
|
||||
*show-environment*) [[ -z ${MANAGER_FAIL-} ]] || exit 1
|
||||
# print_variable() prints every value the way a shell would read it, so the
|
||||
# fixture, not this stub, decides how the value is quoted.
|
||||
echo "XDG_CONFIG_HOME=${CONFIG_HOME_RAW-${CONFIG_HOME-}}" ;;
|
||||
*property=ExecStart*) echo "${EFFECTIVE-}" ;;
|
||||
*property=LoadState*) echo "${LOAD_STATE-loaded}" ;;
|
||||
# A unit that failed stays failed after it is stopped, and systemd refuses
|
||||
# reset-failed for every other state, reporting the unit as not loaded.
|
||||
*property=ActiveState*) echo "${ACTIVE_STATE-inactive}" ;;
|
||||
*" reset-failed "*) [[ ${ACTIVE_STATE-inactive} == failed && -z ${RESET_FAIL-} ]] || {
|
||||
printf 'Failed to reset failed state of unit: Unit is not loaded.\n' >&2; exit 1; } ;;
|
||||
*" stop "*) [[ -z ${STOP_FAIL-} ]] || exit 1 ;;
|
||||
esac
|
||||
''')
|
||||
|
||||
def executable(self, name, source):
|
||||
path = self.bin / name
|
||||
path.write_text(source)
|
||||
path.chmod(0o755)
|
||||
|
||||
def online(self):
|
||||
sock = socket.socket(socket.AF_UNIX)
|
||||
sock.bind(str(self.runtime / "bus"))
|
||||
self.addCleanup(sock.close)
|
||||
|
||||
def install(self, app, binary=None):
|
||||
unit = self.units / f"{app}.service"
|
||||
unit.write_text(f'[Service]\nExecStart="{binary or "/usr/bin/" + app}" --config "{self.home}/config.toml" daemon\n')
|
||||
target = self.units / "graphical-session.target.wants"
|
||||
target.mkdir(exist_ok=True)
|
||||
link = target / unit.name
|
||||
link.symlink_to(f"../{unit.name}")
|
||||
return unit, link
|
||||
|
||||
def run_remove(self, app):
|
||||
self.log.unlink(missing_ok=True) # Every run is judged on its own calls.
|
||||
return subprocess.run(["bash", str(ROOT / f"pkgbuilds/{app}-bin/package-remove"),
|
||||
"--user", app, str(self.home), str(self.runtime)],
|
||||
env=self.env, text=True, capture_output=True)
|
||||
|
||||
def test_logged_out_users_and_data_preservation(self):
|
||||
for app in ("omawake", "omaspeak"):
|
||||
unit, link = self.install(app)
|
||||
config = self.home / f"{app}.toml"
|
||||
config.write_text("keep settings and models")
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
self.assertEqual(config.read_text(), "keep settings and models")
|
||||
self.assertFalse(self.log.exists(), "offline cleanup contacted systemd")
|
||||
|
||||
def test_active_unit_is_stopped_before_removing_it(self):
|
||||
self.online()
|
||||
for app in ("omawake", "omaspeak"):
|
||||
unit, link = self.install(app)
|
||||
self.env["EFFECTIVE"] = f"{{ path=/usr/bin/{app} ; argv[]=/usr/bin/{app} daemon ; }}"
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
calls = self.log.read_text().splitlines()
|
||||
self.assertLess(calls.index(f"--user stop {app}.service"), calls.index(f"--user disable {app}.service"))
|
||||
self.assertEqual(calls[-1], "--user daemon-reload")
|
||||
|
||||
def test_failed_stop_prevents_unit_deletion_and_fails_hook(self):
|
||||
self.online()
|
||||
unit, link = self.install("omawake")
|
||||
self.env.update(STOP_FAIL="1", EFFECTIVE="{ path=/usr/bin/omawake ; }")
|
||||
result = self.run_remove("omawake")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertNotIn("disable", self.log.read_text())
|
||||
|
||||
def test_reset_failed_is_requested_only_for_a_unit_that_failed(self):
|
||||
self.online()
|
||||
for app in ("omawake", "omaspeak"):
|
||||
unit, link = self.install(app)
|
||||
self.env["EFFECTIVE"] = f"{{ path=/usr/bin/{app} ; argv[]=/usr/bin/{app} daemon ; }}"
|
||||
# A loaded unit that never failed is not failed, and asking systemd to
|
||||
# reset it fails with "Unit is not loaded": that must not abort removal.
|
||||
self.env["ACTIVE_STATE"] = "active"
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
calls = [call for call in self.log.read_text().splitlines() if call.startswith("--user")]
|
||||
self.assertNotIn(f"--user reset-failed {app}.service", calls)
|
||||
# Reading the manager's state is welcome; the only changes asked for
|
||||
# are the stop, the disable and the reload that follow them.
|
||||
self.assertEqual([call for call in calls
|
||||
if "show-environment" not in call and "--property=" not in call],
|
||||
[f"--user stop {app}.service",
|
||||
f"--user disable {app}.service", "--user daemon-reload"])
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
|
||||
# A unit that failed does keep that state once stopped, and there the
|
||||
# reset belongs between stopping the service and disabling the unit.
|
||||
unit, link = self.install(app)
|
||||
self.env["ACTIVE_STATE"] = "failed"
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
calls = self.log.read_text().splitlines()
|
||||
reset = f"--user reset-failed {app}.service"
|
||||
self.assertIn(reset, calls)
|
||||
self.assertLess(calls.index(f"--user stop {app}.service"), calls.index(reset))
|
||||
self.assertLess(calls.index(reset), calls.index(f"--user disable {app}.service"))
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
del self.env["ACTIVE_STATE"]
|
||||
|
||||
def test_reset_refused_by_a_healthy_manager_still_fails_the_transaction(self):
|
||||
self.online()
|
||||
unit, link = self.install("omaspeak")
|
||||
self.env.update(EFFECTIVE="{ path=/usr/bin/omaspeak ; }",
|
||||
ACTIVE_STATE="failed", RESET_FAIL="1")
|
||||
result = self.run_remove("omaspeak")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
self.assertTrue(link.is_symlink())
|
||||
|
||||
def test_custom_build_and_mask_are_preserved(self):
|
||||
unit, link = self.install("omawake", "/home/user/dev/omawake")
|
||||
self.assertEqual(self.run_remove("omawake").returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
self.assertTrue(link.is_symlink())
|
||||
unit.unlink()
|
||||
unit.symlink_to("/dev/null")
|
||||
self.assertEqual(self.run_remove("omawake").returncode, 0)
|
||||
self.assertTrue(unit.is_symlink())
|
||||
self.assertFalse(self.log.exists())
|
||||
|
||||
def test_effective_override_and_missing_online_unit(self):
|
||||
self.online()
|
||||
unit, _ = self.install("omaspeak")
|
||||
self.env["EFFECTIVE"] = "{ path=/home/user/development/omaspeak ; }"
|
||||
self.assertEqual(self.run_remove("omaspeak").returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
self.assertNotIn(" stop ", self.log.read_text())
|
||||
unit.unlink()
|
||||
self.env.update(EFFECTIVE="", LOAD_STATE="not-found")
|
||||
self.assertEqual(self.run_remove("omaspeak").returncode, 0)
|
||||
self.assertNotIn(" stop ", self.log.read_text())
|
||||
|
||||
def test_manager_config_home_and_unavailable_manager(self):
|
||||
self.online()
|
||||
default = self.units
|
||||
self.units = self.home / "custom-config/systemd/user"
|
||||
self.units.mkdir(parents=True)
|
||||
unit, link = self.install("omawake")
|
||||
self.env.update(CONFIG_HOME=str(self.home / "custom-config"), MANAGER_FAIL="1")
|
||||
self.assertNotEqual(self.run_remove("omawake").returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
del self.env["MANAGER_FAIL"]
|
||||
self.assertEqual(self.run_remove("omawake").returncode, 0)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
self.assertTrue(default.exists())
|
||||
|
||||
def test_root_dispatch_drops_privileges_and_propagates_failure(self):
|
||||
passwd = f"fixture:x:12345:12345::{self.home}:/bin/bash"
|
||||
self.executable("getent", f"#!/bin/sh\nprintf '%s\\n' '{passwd}'\n")
|
||||
self.executable("runuser", '#!/bin/sh\nprintf "%s\\n" "$*" >> "$CALLS"\nexit 1\n')
|
||||
result = subprocess.run(["bash", str(ROOT / "pkgbuilds/omawake-bin/package-remove"), "omawake"], env=self.env, capture_output=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("-u fixture -- env", self.log.read_text())
|
||||
self.assertIn("--user omawake", self.log.read_text())
|
||||
|
||||
def test_offline_executable_overrides_are_preserved(self):
|
||||
for app in ("omawake", "omaspeak"):
|
||||
for spacing in ("ExecStart=\nExecStart={command}",
|
||||
# systemd's parser throws the whitespace around an
|
||||
# assignment away, so both of these spellings still
|
||||
# name a development build, exactly as the first does.
|
||||
"ExecStart =\nExecStart = {command}",
|
||||
"\tExecStart\t=\t{command}"):
|
||||
unit, link = self.install(app)
|
||||
dropins = Path(str(unit) + ".d")
|
||||
dropins.mkdir(exist_ok=True)
|
||||
(dropins / "override.conf").write_text("[Service]\n" + spacing.format(
|
||||
command=f"/home/user/build/{app} daemon") + "\n")
|
||||
try:
|
||||
with self.subTest(app=app, spacing=spacing):
|
||||
self.assertEqual(self.run_remove(app).returncode, 0)
|
||||
self.assertTrue(unit.exists(), "removed a service with an override")
|
||||
self.assertTrue(link.is_symlink(), "unlinked a service with an override")
|
||||
finally:
|
||||
unit.unlink(missing_ok=True)
|
||||
link.unlink(missing_ok=True)
|
||||
self.assertFalse(self.log.exists(), "offline cleanup contacted systemd")
|
||||
|
||||
def test_shell_quoted_manager_config_home_is_resolved(self):
|
||||
self.online()
|
||||
default_units = self.units
|
||||
for app in ("omawake", "omaspeak"):
|
||||
config_home = self.home / f"{app} custom's \\ config"
|
||||
self.units = config_home / "systemd/user"
|
||||
self.units.mkdir(parents=True)
|
||||
unit, link = self.install(app)
|
||||
# A unit in the directory the manager reads nothing from is no unit of
|
||||
# the manager's, and the helper has no business reaching for it.
|
||||
stray = default_units / f"{app}.service"
|
||||
stray.write_text(f'[Service]\nExecStart="/usr/bin/{app}" daemon\n')
|
||||
printed = self.env["CONFIG_HOME_RAW"] = systemd_environment_value(str(config_home))
|
||||
with self.subTest(app=app, printed=printed):
|
||||
self.assertTrue(printed.startswith("$'") and printed.endswith("'"),
|
||||
"a path of spaces is not what a plain value looks like")
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
self.assertTrue(stray.is_file(), "guessed at a directory no manager reads")
|
||||
del self.env["CONFIG_HOME_RAW"]
|
||||
|
||||
def test_control_character_manager_config_home_is_resolved(self):
|
||||
self.online()
|
||||
for app in ("omawake", "omaspeak"):
|
||||
for suffix in ("tab\tpath", "newline\npath\n", "\a\b\f\r\v", "\x01\x1b\x7f"):
|
||||
config_home = self.home / (app + suffix)
|
||||
self.units = config_home / "systemd/user"
|
||||
self.units.mkdir(parents=True)
|
||||
unit, link = self.install(app)
|
||||
self.env["CONFIG_HOME_RAW"] = systemd_environment_value(str(config_home))
|
||||
with self.subTest(app=app, suffix=suffix):
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
|
||||
def test_unreadable_manager_config_home_aborts_the_cleanup(self):
|
||||
self.online()
|
||||
for app in ("omawake", "omaspeak"):
|
||||
unit, link = self.install(app)
|
||||
# Truncated output must not make cleanup guess at a directory.
|
||||
self.env["CONFIG_HOME_RAW"] = "$'" + str(self.home / f"broken {app} config")
|
||||
with self.subTest(app=app):
|
||||
result = self.run_remove(app)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertNotIn(" stop ", self.log.read_text())
|
||||
del self.env["CONFIG_HOME_RAW"]
|
||||
|
||||
def test_relative_manager_config_home_keeps_the_default_directory(self):
|
||||
self.online()
|
||||
for app in ("omawake", "omaspeak"):
|
||||
unit, link = self.install(app)
|
||||
# An XDG_CONFIG_HOME that is not absolute is no setting at all: the
|
||||
# manager itself reads the home's .config directory then.
|
||||
self.env["CONFIG_HOME_RAW"] = systemd_environment_value(f"relative {app} config")
|
||||
with self.subTest(app=app):
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
del self.env["CONFIG_HOME_RAW"]
|
||||
|
||||
def test_packaging_installs_hooks_and_helpers(self):
|
||||
import shutil
|
||||
for app, version in (("omawake", "0.0.3"), ("omaspeak", "0.0.3")):
|
||||
source = self.root / app / "src"
|
||||
package = self.root / app / "pkg"
|
||||
release = source / f"{app}-{version}-linux-x86_64"
|
||||
release.mkdir(parents=True)
|
||||
for path in [app, "lib/libaudiocpp.so.0.1.0", f"packaging/systemd/{app}.service",
|
||||
"README.md", "INSTALL.md", "ACCELERATOR_SETUP.md", "CHANGELOG.md",
|
||||
"RELEASE_NOTES.md", "DEMO.md", "RUNTIME.md", "config.example.toml",
|
||||
"licenses/LICENSE", "assets/fixture", "benchmarks/fixture"]:
|
||||
target = release / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("fixture")
|
||||
directory = ROOT / f"pkgbuilds/{app}-bin"
|
||||
for name in ("package-remove", "remove-user-services.hook"):
|
||||
shutil.copyfile(directory / name, source / name)
|
||||
env = dict(self.env, srcdir=str(source), pkgdir=str(package), CARCH="x86_64")
|
||||
result = subprocess.run(["bash", "-c", 'source "$1"; package', "package-fixture", str(directory / "PKGBUILD")], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
helper = package / f"usr/lib/{app}/package-remove"
|
||||
self.assertEqual(helper.read_bytes(), (directory / "package-remove").read_bytes())
|
||||
self.assertEqual(helper.stat().st_mode & 0o777, 0o755)
|
||||
self.assertTrue((package / f"usr/share/libalpm/hooks/30-{app}-remove-user-services.hook").exists())
|
||||
|
||||
def test_hook_contract_and_package_release(self):
|
||||
scripts = []
|
||||
for app in ("omawake", "omaspeak"):
|
||||
directory = ROOT / f"pkgbuilds/{app}-bin"
|
||||
hook = (directory / "remove-user-services.hook").read_text()
|
||||
self.assertIn("Operation = Remove", hook)
|
||||
self.assertNotIn("Operation = Upgrade", hook)
|
||||
self.assertIn("When = PreTransaction", hook)
|
||||
self.assertIn("AbortOnFail", hook)
|
||||
self.assertIn(f"Exec = /usr/lib/{app}/package-remove {app}", hook)
|
||||
self.assertIn("pkgrel=4", (directory / "PKGBUILD").read_text())
|
||||
scripts.append((directory / "package-remove").read_bytes())
|
||||
self.assertEqual(*scripts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
#!/bin/bash
|
||||
# Exercise release/queue handling without publishing to an external repository.
|
||||
set -euo pipefail
|
||||
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
||||
TEST_ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TEST_ROOT"' EXIT
|
||||
export TEST_ROOT
|
||||
export OMARCHY_STATE_DIR="$TEST_ROOT/state"
|
||||
unset OMARCHY_REPO_ROOT OMARCHY_KEEP_BUILD_WORKSPACE OMARCHY_DEFER_RUNTIME_DEPS
|
||||
mkdir -p "$TEST_ROOT/bin" "$TEST_ROOT/state"
|
||||
cp "$BUILD_ROOT/bin/"{release,auto-release,check-versions} "$TEST_ROOT/bin/"
|
||||
cp -r "$BUILD_ROOT/helpers" "$BUILD_ROOT/build" "$TEST_ROOT/"
|
||||
cat >> "$TEST_ROOT/helpers/basecamp-notifier.sh" <<'EOF'
|
||||
notify_basecamp() { printf '%s\n' "$1" >> "$TEST_ROOT/notifications"; }
|
||||
EOF
|
||||
|
||||
cat > "$TEST_ROOT/bin/build" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
[[ "$MODE" == plan ]] && exit 0
|
||||
out="$TEST_ROOT/build-output/edge/x86_64"
|
||||
mkdir -p "$out"
|
||||
echo complete > "$out/good-1-1-x86_64.pkg.tar.zst"
|
||||
[[ "$MODE" == infrastructure ]] && exit 1
|
||||
[[ "$MODE" == interrupted ]] && exit 2
|
||||
mkdir -p "$OMARCHY_BUILD_RESULT_DIR"
|
||||
: > "$OMARCHY_BUILD_RESULT_DIR/artifacts"
|
||||
: > "$OMARCHY_BUILD_RESULT_DIR/failed"
|
||||
: > "$OMARCHY_BUILD_RESULT_DIR/blocked"
|
||||
if [[ "$MODE" != all_failed ]]; then
|
||||
echo good-1-1-x86_64.pkg.tar.zst > "$OMARCHY_BUILD_RESULT_DIR/artifacts"
|
||||
fi
|
||||
touch "$OMARCHY_BUILD_RESULT_DIR/complete"
|
||||
[[ "$MODE" == success ]] && exit 0
|
||||
[[ "$MODE" == missing_artifact ]] && echo missing-1-1-x86_64.pkg.tar.zst >> "$OMARCHY_BUILD_RESULT_DIR/artifacts"
|
||||
# A failed split build may have copied its first output before failing.
|
||||
echo incomplete > "$out/bad-first-1-1-x86_64.pkg.tar.zst"
|
||||
echo bad > "$OMARCHY_BUILD_RESULT_DIR/failed"
|
||||
echo blocked > "$OMARCHY_BUILD_RESULT_DIR/blocked"
|
||||
exit 2
|
||||
EOF
|
||||
cat > "$TEST_ROOT/bin/repo" <<'EOF'
|
||||
#!/bin/bash
|
||||
shift
|
||||
exec "$(dirname "$0")/release" "$@"
|
||||
EOF
|
||||
cat > "$TEST_ROOT/bin/stage" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
stage=$(basename "$0")
|
||||
echo "$stage" >> "$TEST_ROOT/stages"
|
||||
out="$TEST_ROOT/build-output/edge/x86_64"
|
||||
repo="$TEST_ROOT/pkgs.omarchy.org/edge/x86_64"
|
||||
case "$stage" in
|
||||
sign)
|
||||
[[ "$MODE" == signing ]] && exit 1
|
||||
for file in "$out"/*.pkg.tar.zst; do
|
||||
echo "signed $(basename "$file")" >> "$TEST_ROOT/signed"
|
||||
touch "$file.sig"
|
||||
done
|
||||
;;
|
||||
promote-build)
|
||||
mkdir -p "$repo"
|
||||
mv "$out"/*.pkg.tar.zst* "$repo/"
|
||||
;;
|
||||
update-repo)
|
||||
mkdir -p "$TEST_ROOT/db/good-1-1"
|
||||
printf '%%NAME%%\ngood\n\n%%BASE%%\ngood\n\n%%VERSION%%\n1-1\n' > "$TEST_ROOT/db/good-1-1/desc"
|
||||
tar --zstd -cf "$repo/omarchy.db.tar.zst" -C "$TEST_ROOT/db" good-1-1
|
||||
ln -s omarchy.db.tar.zst "$repo/omarchy.db"
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
for stage in sign promote-build clean-repo update-repo sync-repo; do
|
||||
cp "$TEST_ROOT/bin/stage" "$TEST_ROOT/bin/$stage"
|
||||
done
|
||||
chmod +x "$TEST_ROOT/bin/"*
|
||||
|
||||
for package in good bad blocked; do
|
||||
mkdir -p "$TEST_ROOT/pkgbuilds/$package/.omarchy"
|
||||
printf '{"source":"local"}\n' > "$TEST_ROOT/pkgbuilds/$package/.omarchy/package.json"
|
||||
printf "pkgname=%s\npkgver=1\npkgrel=1\narch=('x86_64')\n" "$package" > "$TEST_ROOT/pkgbuilds/$package/PKGBUILD"
|
||||
done
|
||||
echo "depends=('bad')" >> "$TEST_ROOT/pkgbuilds/blocked/PKGBUILD"
|
||||
|
||||
reset_case() {
|
||||
rm -rf "$TEST_ROOT/build-output" "$TEST_ROOT/pkgs.omarchy.org" "$TEST_ROOT/state" "$TEST_ROOT/db"
|
||||
mkdir -p "$TEST_ROOT/state"
|
||||
: > "$TEST_ROOT/stages"
|
||||
: > "$TEST_ROOT/signed"
|
||||
: > "$TEST_ROOT/notifications"
|
||||
printf 'good\nbad\nblocked\n' > "$TEST_ROOT/state/.sync-needed-edge-x86_64"
|
||||
}
|
||||
|
||||
reset_case
|
||||
export MODE=partial
|
||||
if "$TEST_ROOT/bin/auto-release" edge x86_64 > "$TEST_ROOT/partial.log" 2>&1; then
|
||||
echo 'FAIL: partial release cleared failure status' >&2; exit 1
|
||||
fi
|
||||
[[ $(cat "$TEST_ROOT/stages") == $'sign\npromote-build\nclean-repo\nupdate-repo\nsync-repo' ]]
|
||||
[[ $(cat "$TEST_ROOT/signed") == 'signed good-1-1-x86_64.pkg.tar.zst' ]]
|
||||
[[ -f "$TEST_ROOT/pkgs.omarchy.org/edge/x86_64/good-1-1-x86_64.pkg.tar.zst" ]]
|
||||
[[ ! -e "$TEST_ROOT/pkgs.omarchy.org/edge/x86_64/bad-first-1-1-x86_64.pkg.tar.zst" ]]
|
||||
[[ -f "$TEST_ROOT/state/.build-failed-edge-x86_64" ]]
|
||||
grep -q 'Partial release published: edge' "$TEST_ROOT/notifications"
|
||||
grep -q 'Failed: bad' "$TEST_ROOT/notifications"
|
||||
grep -q 'Blocked by failed dependencies: blocked' "$TEST_ROOT/notifications"
|
||||
"$TEST_ROOT/bin/check-versions" > "$TEST_ROOT/versions.log" 2>&1
|
||||
[[ $(cat "$TEST_ROOT/state/.sync-needed-edge-x86_64") == $'bad\nblocked' ]]
|
||||
ARCH=x86_64 MIRROR=edge DRY_RUN=true PKGBUILDS_DIR="$TEST_ROOT/pkgbuilds" \
|
||||
FINAL_OUTPUT_DIR="$TEST_ROOT/pkgs.omarchy.org/edge/x86_64" HELPERS_DIR="$TEST_ROOT/helpers" \
|
||||
"$TEST_ROOT/build/build.sh" > "$TEST_ROOT/retry.log" 2>&1
|
||||
grep -q 'good - already up to date' "$TEST_ROOT/retry.log"
|
||||
grep -q 'Build order: bad blocked' "$TEST_ROOT/retry.log"
|
||||
echo 'PASS: successes publish; incomplete outputs stay out; retries skip published versions'
|
||||
|
||||
for MODE in infrastructure interrupted all_failed missing_artifact; do
|
||||
export MODE
|
||||
reset_case
|
||||
if "$TEST_ROOT/bin/release" --mirror edge > "$TEST_ROOT/$MODE.log" 2>&1; then
|
||||
echo "FAIL: $MODE succeeded" >&2; exit 1
|
||||
fi
|
||||
[[ ! -s "$TEST_ROOT/stages" ]]
|
||||
done
|
||||
echo 'PASS: infrastructure failures, missing completion records, and zero successes never publish'
|
||||
|
||||
reset_case
|
||||
export MODE=signing
|
||||
if "$TEST_ROOT/bin/release" --mirror edge > "$TEST_ROOT/signing.log" 2>&1; then
|
||||
echo 'FAIL: signing failure succeeded' >&2; exit 1
|
||||
fi
|
||||
[[ $(cat "$TEST_ROOT/stages") == sign ]]
|
||||
echo 'PASS: signing failure stops promotion and sync'
|
||||
|
||||
reset_case
|
||||
export MODE=partial
|
||||
if OMARCHY_DEFER_RUNTIME_DEPS=true "$TEST_ROOT/bin/release" --mirror edge > "$TEST_ROOT/pair.log" 2>&1; then
|
||||
echo 'FAIL: incomplete deferred release pair published' >&2; exit 1
|
||||
fi
|
||||
[[ ! -s "$TEST_ROOT/stages" ]]
|
||||
echo 'PASS: deferred release pairs cannot publish partially'
|
||||
|
||||
reset_case
|
||||
export MODE=success
|
||||
"$TEST_ROOT/bin/auto-release" edge x86_64 > "$TEST_ROOT/success.log" 2>&1
|
||||
[[ ! -e "$TEST_ROOT/state/.sync-needed-edge-x86_64" ]]
|
||||
grep -q 'Release published: edge' "$TEST_ROOT/notifications"
|
||||
echo 'PASS: complete success publishes and clears the queue'
|
||||
|
||||
reset_case
|
||||
export MODE=plan
|
||||
"$TEST_ROOT/bin/release" --mirror edge --dry-run > "$TEST_ROOT/plan.log" 2>&1
|
||||
[[ ! -s "$TEST_ROOT/stages" && ! -s "$TEST_ROOT/notifications" ]]
|
||||
echo 'PASS: dry runs neither publish nor notify'
|
||||
@@ -0,0 +1,314 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync, mkdtempSync, rmSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { test } = require('node:test');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const approve = require('../.github/scripts/approve-pr-workflows.cjs');
|
||||
|
||||
const BUILD = '.github/workflows/build-pr.yml';
|
||||
const TESTS = '.github/workflows/test.yml';
|
||||
const time = '2026-09-19T02:47:52Z';
|
||||
const earlier = '2026-09-19T02:36:04Z';
|
||||
const pr = {
|
||||
number: 390, state: 'open', updated_at: time,
|
||||
head: { sha: 'reviewed-sha', ref: 'ghost', repo: { id: 42 } },
|
||||
labels: [{ name: 'build-approved' }],
|
||||
};
|
||||
const clone = value => structuredClone(value);
|
||||
const run = (id, path, overrides = {}) => ({
|
||||
id, path, event: 'pull_request', head_sha: pr.head.sha,
|
||||
head_repository: { id: 42 }, head_branch: 'ghost', pull_requests: [],
|
||||
status: 'completed', conclusion: 'action_required', created_at: time,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function fixture(initial = [run(1, TESTS, { created_at: earlier }), run(2, BUILD)], options = {}) {
|
||||
const state = { pr: clone(pr), runs: clone(initial), approved: [], reads: 0, tick: 0, transitions: [] };
|
||||
const repo = { owner: 'omacom', repo: 'omarchy-pkgs' };
|
||||
const github = {
|
||||
rest: {
|
||||
pulls: { get: async args => {
|
||||
assert.deepEqual(args, { ...repo, pull_number: 390 });
|
||||
state.reads++;
|
||||
options.onRead?.(state);
|
||||
return { data: clone(state.pr) };
|
||||
} },
|
||||
actions: {
|
||||
listWorkflowRunsForRepo() {},
|
||||
getWorkflowRun: async ({ run_id }) => {
|
||||
const current = state.runs.find(run => run.id === run_id);
|
||||
state.transitions.push([run_id, current.status]);
|
||||
return { data: clone(current) };
|
||||
},
|
||||
approveWorkflowRun: async args => {
|
||||
assert.deepEqual(args, { ...repo, run_id: args.run_id });
|
||||
options.onApprove?.(state, args.run_id);
|
||||
const current = state.runs.find(run => run.id === args.run_id);
|
||||
assert.equal(current.conclusion, 'action_required');
|
||||
state.approved.push(current.id);
|
||||
current.status = 'queued';
|
||||
current.conclusion = null;
|
||||
},
|
||||
},
|
||||
},
|
||||
paginate: async (method, args) => {
|
||||
assert.equal(method, github.rest.actions.listWorkflowRunsForRepo);
|
||||
assert.deepEqual(args, { ...repo, event: 'pull_request', head_sha: pr.head.sha, per_page: 100 });
|
||||
return clone(state.runs).reverse(); // GitHub returns newest first.
|
||||
},
|
||||
};
|
||||
const invoke = overrides => approve({
|
||||
github, context: { repo, payload: { action: 'labeled', pull_request: clone(pr) } },
|
||||
core: { info() {} }, vouchStatus: 'unknown', attempts: 6,
|
||||
sleep: async () => {
|
||||
state.tick++;
|
||||
for (const current of state.runs) {
|
||||
if (current.status === 'queued' && state.tick >= (options.queueUntil ?? 1)) current.status = 'in_progress';
|
||||
}
|
||||
options.onSleep?.(state);
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
return { state, invoke, github };
|
||||
}
|
||||
|
||||
test('an unvouched, labeled fork PR releases both required workflows', async () => {
|
||||
const { state, invoke } = fixture();
|
||||
await invoke();
|
||||
assert.deepEqual(state.approved, [1, 2]);
|
||||
});
|
||||
|
||||
test('waits for the label-triggered build instead of stopping at the old build', async () => {
|
||||
const { state, invoke } = fixture([
|
||||
run(1, BUILD, { created_at: earlier }), run(2, TESTS, { created_at: earlier }),
|
||||
], {
|
||||
onSleep(state) {
|
||||
if (state.tick === 2) {
|
||||
assert.deepEqual(state.approved, []);
|
||||
state.runs.push(run(3, BUILD));
|
||||
}
|
||||
},
|
||||
queueUntil: 4,
|
||||
onApprove(state, id) {
|
||||
if (id === 3) assert.equal(state.runs.find(run => run.id === 1).status, 'in_progress');
|
||||
},
|
||||
});
|
||||
await invoke({ attempts: 10 });
|
||||
assert.deepEqual(state.approved, [1, 2, 3]);
|
||||
assert.ok(state.transitions.some(([id, status]) => id === 1 && status === 'queued'));
|
||||
});
|
||||
|
||||
test('approves only the two known workflows for this fork, branch, PR and SHA', async () => {
|
||||
const unrelated = [
|
||||
{ path: '.github/workflows/publish.yml' }, { event: 'push' },
|
||||
{ head_sha: 'other-sha' }, { head_repository: { id: 99 } },
|
||||
{ head_branch: 'other-branch' }, { pull_requests: [{ number: 391 }] },
|
||||
].map((overrides, i) => run(10 + i, BUILD, overrides));
|
||||
const { state, invoke } = fixture([run(1, TESTS), run(2, BUILD), ...unrelated]);
|
||||
await invoke();
|
||||
assert.deepEqual(state.approved, [1, 2]);
|
||||
});
|
||||
|
||||
test('accepts a run explicitly associated with this PR', async () => {
|
||||
const { state, invoke } = fixture([
|
||||
run(1, TESTS), run(2, BUILD, { pull_requests: [{ number: 390 }] }),
|
||||
]);
|
||||
await invoke();
|
||||
assert.deepEqual(state.approved, [1, 2]);
|
||||
});
|
||||
|
||||
test('does not restart running or completed workflows', async () => {
|
||||
const { state, invoke } = fixture([
|
||||
run(1, TESTS, { conclusion: 'success' }),
|
||||
run(2, BUILD, { status: 'in_progress', conclusion: null }),
|
||||
]);
|
||||
await invoke();
|
||||
assert.deepEqual(state.approved, []);
|
||||
});
|
||||
|
||||
test('an obsolete build hold cannot cancel a newer build that was already released', async () => {
|
||||
for (const current of [
|
||||
{ status: 'queued', conclusion: null }, { status: 'in_progress', conclusion: null },
|
||||
{ status: 'completed', conclusion: 'success' }, { status: 'completed', conclusion: 'failure' },
|
||||
]) {
|
||||
const { state, invoke } = fixture([
|
||||
run(1, BUILD, { created_at: earlier }), run(2, TESTS), run(3, BUILD, current),
|
||||
]);
|
||||
await invoke();
|
||||
assert.deepEqual(state.approved, [2]);
|
||||
}
|
||||
});
|
||||
|
||||
for (const status of ['denounced', '', undefined, 'unexpected']) {
|
||||
test(`vouch status ${String(status)} fails closed`, async () => {
|
||||
const { state, invoke } = fixture();
|
||||
await assert.rejects(invoke({ vouchStatus: status }), /Cannot approve workflows/);
|
||||
assert.deepEqual(state.approved, []);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of ['bot', 'collaborator', 'vouched']) {
|
||||
test(`a labeled ${status} can also clear GitHub's approval gate`, async () => {
|
||||
const { state, invoke } = fixture();
|
||||
await invoke({ vouchStatus: status });
|
||||
assert.deepEqual(state.approved, [1, 2]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, change] of [
|
||||
['removed label', pr => { pr.labels = []; }],
|
||||
['changed head', pr => { pr.head.sha = 'new-sha'; }],
|
||||
['closed PR', pr => { pr.state = 'closed'; }],
|
||||
]) {
|
||||
test(`${name} stops approval, including changes immediately before a write`, async () => {
|
||||
for (const read of [1, 2]) {
|
||||
const { state, invoke } = fixture(undefined, { onRead(state) {
|
||||
if (state.reads === read) change(state.pr);
|
||||
} });
|
||||
await invoke();
|
||||
assert.deepEqual(state.approved, []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('revocation between approvals prevents releasing further workflows', async () => {
|
||||
const { state, invoke } = fixture(undefined, { onSleep(state) { state.pr.labels = []; } });
|
||||
await invoke();
|
||||
assert.deepEqual(state.approved, [1]);
|
||||
});
|
||||
|
||||
test('a delayed tests workflow is also awaited', async () => {
|
||||
const { state, invoke } = fixture([run(2, BUILD)], {
|
||||
onSleep(state) { if (state.tick === 2) state.runs.push(run(1, TESTS)); },
|
||||
});
|
||||
await invoke();
|
||||
assert.deepEqual(state.approved, [1, 2]);
|
||||
});
|
||||
|
||||
test('reopening a labeled PR waits for its new tests, even if old tests passed at the same SHA', async () => {
|
||||
const { state, invoke } = fixture([
|
||||
run(1, TESTS, { created_at: earlier, conclusion: 'success' }), run(2, BUILD),
|
||||
], { onSleep(state) { if (state.tick === 2) state.runs.push(run(3, TESTS)); } });
|
||||
await invoke({ context: { repo: { owner: 'omacom', repo: 'omarchy-pkgs' },
|
||||
payload: { action: 'reopened', pull_request: clone(pr) } } });
|
||||
assert.deepEqual(state.approved, [2, 3]);
|
||||
});
|
||||
|
||||
test('missing current runs time out without approving stale builds', async () => {
|
||||
const { state, invoke } = fixture([run(1, TESTS), run(2, BUILD, { created_at: earlier })]);
|
||||
await assert.rejects(invoke(), /Timed out/);
|
||||
assert.deepEqual(state.approved, []);
|
||||
});
|
||||
|
||||
test('API failure is reported rather than silently treated as approval', async () => {
|
||||
const { state, invoke } = fixture(undefined, { onApprove() { throw new Error('Forbidden'); } });
|
||||
await assert.rejects(invoke(), /Forbidden/);
|
||||
assert.deepEqual(state.approved, []);
|
||||
});
|
||||
|
||||
// Execute the actual build workflow's approval script and shell gate. This
|
||||
// covers the stale event payload that originally accompanied held PR runs.
|
||||
const workflow = readFileSync(join(__dirname, '../.github/workflows/build-pr.yml'), 'utf8');
|
||||
const approvalScript = workflow.match(/- id: approval[\s\S]*?script: \|\n([\s\S]*?)(?= # One matrix)/)[1]
|
||||
.split('\n').map(line => line.replace(/^ /, '')).join('\n');
|
||||
const gateScript = workflow.match(/ case "\$STATUS" in[\s\S]*? esac/)[0] + '\nprintf "%s" "$trusted"';
|
||||
|
||||
test('the build reads the live label rather than its pre-label event payload', async () => {
|
||||
const execute = new (Object.getPrototypeOf(async function () {}).constructor)('github', 'context', 'core', approvalScript);
|
||||
for (const [current, approved] of [
|
||||
[pr, true], [{ ...pr, labels: [] }, false],
|
||||
[{ ...pr, head: { ...pr.head, sha: 'new-sha' } }, false],
|
||||
[{ ...pr, state: 'closed' }, false],
|
||||
]) {
|
||||
const outputs = {};
|
||||
await execute({ rest: { pulls: { get: async () => ({ data: current }) } } },
|
||||
{ repo: {}, payload: { pull_request: { ...pr, labels: [] } } },
|
||||
{ setOutput: (key, value) => { outputs[key] = value; } });
|
||||
assert.equal(outputs.approved, approved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the build gate permits a missing vouch only with approval, never a denouncement or lookup failure', () => {
|
||||
for (const [status, approved, expected] of [
|
||||
['unknown', 'true', 'true'], ['unknown', 'false', 'false'],
|
||||
['denounced', 'true', 'false'], ['', 'true', 'false'], ['unexpected', 'true', 'false'],
|
||||
['vouched', 'false', 'true'], ['collaborator', 'false', 'true'],
|
||||
['bot', 'false', 'true'], ['dispatch', 'false', 'true'],
|
||||
]) {
|
||||
assert.equal(execFileSync('bash', ['-c', gateScript], {
|
||||
env: { ...process.env, STATUS: status, APPROVED: approved }, encoding: 'utf8',
|
||||
}), expected);
|
||||
}
|
||||
});
|
||||
|
||||
// Exercise the actual reporting job, including its GitHub check name: a
|
||||
// successful/skipped check called "result" would accidentally allow merging
|
||||
// a PR whose build never ran. GitHub keeps a missing required check pending.
|
||||
const resultJob = workflow.slice(workflow.indexOf('\n result:\n'));
|
||||
const resultName = resultJob.match(/^ name: (.+)$/m)[1];
|
||||
const resultScript = resultJob.split(' - run: |\n')[1];
|
||||
function report({ trusted = 'false', vouch = 'unknown', empty = 'false', changes = 'success', build = 'skipped' } = {}) {
|
||||
const needs = {
|
||||
changes: { result: changes, outputs: { trusted, vouch_status: vouch, empty } },
|
||||
build: { result: build },
|
||||
};
|
||||
// The reporting expressions use &&, || and string equality, with the
|
||||
// same semantics in JavaScript and Actions for these string-only fixtures.
|
||||
const render = text => text.replace(/\$\{\{(.*?)\}\}/g, (_, expression) =>
|
||||
new Function('needs', `return (${expression})`)(needs));
|
||||
const directory = mkdtempSync(join(tmpdir(), 'build-approval-report-'));
|
||||
const summaryPath = join(directory, 'summary');
|
||||
try {
|
||||
const result = spawnSync('bash', ['-e', '-c', render(resultScript)], {
|
||||
env: { ...process.env, GITHUB_STEP_SUMMARY: summaryPath }, encoding: 'utf8',
|
||||
});
|
||||
return { name: render(resultName), ...result,
|
||||
summary: result.stdout.includes('::notice::') ? readFileSync(summaryPath, 'utf8') : '' };
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('an unvouched PR waits without publishing a passing or failing required result', () => {
|
||||
const result = report();
|
||||
assert.equal(result.name, 'Awaiting build approval');
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /::notice::Awaiting maintainer build approval/);
|
||||
assert.doesNotMatch(result.stdout, /::error::/);
|
||||
assert.match(result.summary, /required \*\*result\*\* check remains pending/);
|
||||
});
|
||||
|
||||
test('applying build-approved transitions the waiting PR to the required build result', () => {
|
||||
assert.notEqual(report().name, 'result');
|
||||
const approved = report({ trusted: 'true', build: 'success' });
|
||||
assert.equal(approved.name, 'result');
|
||||
assert.equal(approved.status, 0);
|
||||
const failed = report({ trusted: 'true', build: 'failure' });
|
||||
assert.equal(failed.name, 'result');
|
||||
assert.notEqual(failed.status, 0);
|
||||
});
|
||||
|
||||
test('trusted tooling-only PRs still satisfy the required result without a package build', () => {
|
||||
const result = report({ trusted: 'true', vouch: 'vouched' });
|
||||
assert.equal(result.name, 'result');
|
||||
assert.equal(result.status, 0);
|
||||
});
|
||||
|
||||
for (const [name, overrides] of [
|
||||
['denounced author', { vouch: 'denounced' }],
|
||||
['failed trust lookup', { vouch: '', changes: 'failure' }],
|
||||
['missing trust result', { vouch: '' }],
|
||||
['missing gate output', { trusted: '' }],
|
||||
['failed planning', { changes: 'failure' }],
|
||||
['cancelled planning', { changes: 'cancelled' }],
|
||||
['empty PR', { empty: 'true' }],
|
||||
['cancelled build', { trusted: 'true', build: 'cancelled' }],
|
||||
]) {
|
||||
test(`${name} fails the required result instead of masquerading as pending approval`, () => {
|
||||
const result = report(overrides);
|
||||
assert.equal(result.name, 'result');
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.doesNotMatch(result.stdout, /::notice::Awaiting maintainer build approval/);
|
||||
});
|
||||
}
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# Self-test for bin/publish-artifact against a local directory as the remote.
|
||||
# Needs repo-add, gpg, rclone, bsdtar (run in the Arch builder/test container).
|
||||
set -euo pipefail
|
||||
ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
||||
T=$(mktemp -d); chmod 755 "$T"; trap 'rm -rf "$T"' EXIT
|
||||
REMOTE="$T/r2"; mkdir -p "$REMOTE"
|
||||
|
||||
# throwaway signing key
|
||||
export GNUPGHOME="$T/g"; mkdir -m700 "$GNUPGHOME"
|
||||
gpg --batch --quiet --passphrase '' --quick-gen-key 'Test <t@t>' ed25519 sign 0 2>/dev/null
|
||||
export GPG_PRIVATE_KEY=$(gpg --batch --armor --export-secret-keys 'Test <t@t>') GPG_PASSPHRASE=''
|
||||
unset GNUPGHOME
|
||||
|
||||
# minimal real packages via makepkg
|
||||
mkpkg() { # mkpkg <name> <pkgrel> <arch> [payload]
|
||||
local d="$T/src/$1-$2${4:+-$4}"; mkdir -p "$d"; cd "$d"
|
||||
printf 'pkgname=%s\npkgver=1.0\npkgrel=%s\narch=(%s)\npackage(){ install -Dm644 /dev/null "$pkgdir/usr/share/%s-%s"; echo "%s" > "$pkgdir/usr/share/%s-%s"; }\n' "$1" "$2" "$3" "$1" "$2" "${4:-payload}" "$1" "$2" > PKGBUILD
|
||||
# CARCH so the PKGINFO records the requested arch (--ignorearch would
|
||||
# stamp the host's).
|
||||
# makepkg refuses to run as root (the CI test container does); build the
|
||||
# fixture as an unprivileged user in that case.
|
||||
if (( EUID == 0 )); then
|
||||
id -u fixture >/dev/null 2>&1 || useradd -m fixture
|
||||
chmod 755 "$T/src"; chown -R fixture "$d"
|
||||
runuser -u fixture -- env CARCH=$3 makepkg -f --nodeps --ignorearch >/dev/null 2>&1
|
||||
else
|
||||
CARCH=$3 makepkg -f --nodeps --ignorearch >/dev/null 2>&1
|
||||
fi
|
||||
ls "$d"/*.pkg.tar.zst
|
||||
}
|
||||
A1=$(mkpkg alpha 1 any); A2=$(mkpkg alpha 2 any); B1=$(mkpkg beta 1 x86_64); C1=$(mkpkg gamma 1 aarch64)
|
||||
|
||||
pub() { "$ROOT/bin/publish-artifact" --remote "$REMOTE" --mirror edge --arch x86_64 "$@" >"$T/out" 2>&1; }
|
||||
entries() { tar -tf "$REMOTE/edge/x86_64/omarchy.db.tar.zst" | grep '/$' | sort | tr '\n' ' '; }
|
||||
pass() { echo "PASS: $1"; }
|
||||
fail() { echo "FAIL: $1"; cat "$T/out"; exit 1; }
|
||||
|
||||
pub "$A1" && [[ "$(entries)" == "alpha-1.0-1/ " ]] && [[ -f "$REMOTE/edge/x86_64/$(basename "$A1").sig" ]] \
|
||||
&& pass "first publish creates db with one entry and a signature" || fail "first publish"
|
||||
|
||||
sum_before=$(sha256sum "$REMOTE/edge/x86_64/$(basename "$A1")")
|
||||
pub "$B1" && [[ "$(entries)" == "alpha-1.0-1/ beta-1.0-1/ " ]] && [[ "$(sha256sum "$REMOTE/edge/x86_64/$(basename "$A1")")" == "$sum_before" ]] \
|
||||
&& pass "second package added incrementally; first file untouched" || fail "incremental add"
|
||||
|
||||
pub "$A2" && [[ "$(entries)" == "alpha-1.0-2/ beta-1.0-1/ " ]] && [[ -f "$REMOTE/edge/x86_64/$(basename "$A1")" ]] \
|
||||
&& pass "new pkgrel replaces the db entry, old file remains on remote" || fail "replace entry"
|
||||
|
||||
# Same bytes again: allowed, idempotent (this is how a fast-ring artifact
|
||||
# reaches rc and stable after edge, and how a re-run recovers).
|
||||
pub "$A2" && grep -q 'identical bytes' "$T/out" && [[ "$(entries)" == "alpha-1.0-2/ beta-1.0-1/ " ]] \
|
||||
&& pass "identical bytes under an existing name: accepted, db unchanged" || fail "identical republish"
|
||||
|
||||
# Orphan repair: a file that reached the remote but whose db entry was lost
|
||||
# (a concurrent publish overwrote the db) is fixed by publishing it again.
|
||||
( cd "$REMOTE/edge/x86_64" && repo-remove --quiet omarchy.db.tar.zst alpha >/dev/null 2>&1 )
|
||||
[[ "$(entries)" == "beta-1.0-1/ " ]] || fail "fixture: could not drop alpha from the db"
|
||||
pub "$A2" && [[ "$(entries)" == "alpha-1.0-2/ beta-1.0-1/ " ]] \
|
||||
&& pass "orphaned file regains its db entry on republish" || fail "orphan repair"
|
||||
|
||||
# Different bytes under an existing name: refused. Build alpha-2 again with
|
||||
# a different payload (makepkg is reproducible, so the content must change).
|
||||
A2b=$(mkpkg alpha 2 any different-payload)
|
||||
[[ "$(md5sum < "$A2")" != "$(md5sum < "$A2b")" ]] || { echo "fixture: rebuilt package is byte-identical, cannot test"; exit 1; }
|
||||
if pub "$A2b"; then fail "different bytes under same filename should refuse"; else grep -q 'DIFFERENT bytes' "$T/out" && pass "different bytes under an existing name refused" || fail "wrong refusal reason"; fi
|
||||
|
||||
if pub "$C1"; then fail "aarch64 package into x86_64 should refuse"; else grep -q 'publishing to x86_64' "$T/out" && pass "wrong-arch package refused" || fail "wrong-arch reason"; fi
|
||||
|
||||
cp "$B1" "$T/renamed-1.0-1-x86_64.pkg.tar.zst"
|
||||
if pub "$T/renamed-1.0-1-x86_64.pkg.tar.zst"; then fail "filename/PKGINFO mismatch should refuse"; else grep -q 'does not match PKGINFO' "$T/out" && pass "filename must match PKGINFO" || fail "mismatch reason"; fi
|
||||
|
||||
# db must verify: pacman can read it and each package's signature checks
|
||||
gpg --batch --quiet --import <<<"$GPG_PRIVATE_KEY" 2>/dev/null || true
|
||||
( cd "$REMOTE/edge/x86_64" && for f in *.pkg.tar.zst; do gpg --batch --quiet --verify "$f.sig" "$f" 2>/dev/null || { echo "FAIL: signature $f"; exit 1; }; done ) && pass "all signatures verify"
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
# The timer and builder must agree when an older archive remains published.
|
||||
set -euo pipefail
|
||||
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
||||
TEST_ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TEST_ROOT"' EXIT
|
||||
mkdir -p "$TEST_ROOT/bin" "$TEST_ROOT/pkgbuilds" "$TEST_ROOT/state"
|
||||
cp "$BUILD_ROOT/bin/check-versions" "$TEST_ROOT/bin/"
|
||||
cp -r "$BUILD_ROOT/helpers" "$BUILD_ROOT/build" "$TEST_ROOT/"
|
||||
export OMARCHY_STATE_DIR="$TEST_ROOT/state"
|
||||
unset OMARCHY_REPO_ROOT OMARCHY_RC_PINS OMARCHY_DEFER_RUNTIME_DEPS
|
||||
|
||||
fixture() {
|
||||
local name=$1 version=$2 release=$3
|
||||
mkdir -p "$TEST_ROOT/pkgbuilds/$name/.omarchy"
|
||||
printf '{"source":"local","channels":["edge"]}\n' > "$TEST_ROOT/pkgbuilds/$name/.omarchy/package.json"
|
||||
cat > "$TEST_ROOT/pkgbuilds/$name/PKGBUILD" <<EOF
|
||||
pkgname=$name
|
||||
pkgver=$version
|
||||
pkgrel=$release
|
||||
arch=('x86_64' 'aarch64')
|
||||
EOF
|
||||
}
|
||||
fixture omarchy 4.0.3 1
|
||||
fixture omarchy-settings 4.0.3 1
|
||||
fixture current 1 1
|
||||
fixture update 2 1
|
||||
fixture rebuild 1 2
|
||||
fixture unindexed 1 1
|
||||
fixture signature-only 1 1
|
||||
fixture other-arch 1 1
|
||||
fixture new-package 1 1
|
||||
|
||||
db_entry() {
|
||||
local name=$1 version=$2
|
||||
mkdir -p "$TEST_ROOT/db/$name-$version"
|
||||
printf '%%NAME%%\n%s\n\n%%BASE%%\n%s\n\n%%VERSION%%\n%s\n' "$name" "$name" "$version" > "$TEST_ROOT/db/$name-$version/desc"
|
||||
}
|
||||
db_entry omarchy 4.0.4rc1-1
|
||||
db_entry omarchy-settings 4.0.4rc1-1
|
||||
for package in current update rebuild; do db_entry "$package" 1-1; done
|
||||
printf '%s\n' new-package other-arch rebuild signature-only update | sort > "$TEST_ROOT/expected"
|
||||
|
||||
for arch in x86_64 aarch64; do
|
||||
repo="$TEST_ROOT/pkgs.omarchy.org/edge/$arch"
|
||||
mkdir -p "$repo"
|
||||
tar --zstd -cf "$repo/omarchy.db.tar.zst" -C "$TEST_ROOT/db" .
|
||||
touch "$repo/omarchy-4.0.3-1-$arch.pkg.tar.zst"
|
||||
touch "$repo/omarchy-settings-4.0.3-1-any.pkg.tar.xz"
|
||||
touch "$repo/unindexed-1-1-$arch.pkg.tar.zst"
|
||||
touch "$repo/rebuild-1-1-$arch.pkg.tar.zst"
|
||||
touch "$repo/signature-only-1-1-$arch.pkg.tar.zst.sig"
|
||||
other=x86_64
|
||||
[[ "$arch" == x86_64 ]] && other=aarch64
|
||||
touch "$repo/other-arch-1-1-$other.pkg.tar.zst"
|
||||
|
||||
"$TEST_ROOT/bin/check-versions" --arch "$arch" > "$TEST_ROOT/check.log" 2>&1
|
||||
sort "$TEST_ROOT/state/.sync-needed-edge-$arch" > "$TEST_ROOT/queue"
|
||||
diff -u "$TEST_ROOT/expected" "$TEST_ROOT/queue"
|
||||
|
||||
for selection in '' 'omarchy omarchy-settings current update rebuild unindexed signature-only other-arch new-package'; do
|
||||
ARCH="$arch" MIRROR=edge DRY_RUN=true PACKAGES="$selection" \
|
||||
PKGBUILDS_DIR="$TEST_ROOT/pkgbuilds" HELPERS_DIR="$TEST_ROOT/helpers" \
|
||||
FINAL_OUTPUT_DIR="$repo" BUILD_PLAN_DIR="$TEST_ROOT/plan" \
|
||||
"$TEST_ROOT/build/build.sh" > "$TEST_ROOT/plan.log" 2>&1
|
||||
sort "$TEST_ROOT/plan/packages" > "$TEST_ROOT/planned"
|
||||
diff -u "$TEST_ROOT/queue" "$TEST_ROOT/planned"
|
||||
grep -q 'omarchy 4.0.3-1 - archive already published' "$TEST_ROOT/plan.log"
|
||||
done
|
||||
printf 'PASS: %s scheduler and explicit/unscoped plans skip retained archives, allow new versions and releases\n' "$arch"
|
||||
done
|
||||
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline integration tests for owned-recipe updates and failure isolation."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location("upstream_watch", ROOT / "helpers/upstream-watch.py")
|
||||
w = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(w)
|
||||
|
||||
|
||||
class WatchTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.work = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.work.cleanup)
|
||||
self.root = Path(self.work.name)
|
||||
self.package = self.root / "package"
|
||||
(self.package / ".omarchy").mkdir(parents=True)
|
||||
self.fetch = w.Fetcher(self.root / "cache")
|
||||
self.write_watch({"github": "owner/tool", "pattern": r"v(?P<version>[0-9.]+)"})
|
||||
self.recipe = self.package / "PKGBUILD"
|
||||
self.recipe.write_text("""pkgname=tool
|
||||
pkgver=1.0
|
||||
pkgrel=3.1
|
||||
arch=('x86_64' 'aarch64')
|
||||
source_x86_64=("https://example.test/tool-$pkgver-x64")
|
||||
source_aarch64=("https://example.test/tool-$pkgver-arm64")
|
||||
sha256sums_x86_64=('old-x64')
|
||||
sha256sums_aarch64=('old-arm64')
|
||||
package() { touch SHOULD-NOT-RUN; }
|
||||
""")
|
||||
|
||||
def write_watch(self, watch):
|
||||
(self.package / ".omarchy/package.json").write_text(json.dumps({"source": "local", "upstream": {"watch": watch}}))
|
||||
|
||||
def release(self, version="2.0", **values):
|
||||
return {"pkgver": version, "values": {"version": version, "tag": "v" + version, **values}, "revision": "", "published_at": "2026-01-01T00:00:00Z"}
|
||||
|
||||
def fake_file(self, url):
|
||||
p = self.root / ("source-" + url.rsplit("/", 1)[-1])
|
||||
p.write_text(url)
|
||||
return p
|
||||
|
||||
def sync_release(self, release, file=None):
|
||||
with patch.object(w, "discover", return_value=[release]), patch.object(self.fetch, "file", side_effect=file or self.fake_file):
|
||||
return w.sync(self.package, self.fetch)
|
||||
|
||||
def test_updates_both_arches_without_running_build_functions(self):
|
||||
result = self.sync_release(self.release())
|
||||
self.assertEqual(result["after"], "0:2.0-1")
|
||||
recipe = w.read_recipe(self.recipe)
|
||||
for arch, upstream in [("x86_64", "x64"), ("aarch64", "arm64")]:
|
||||
file = self.fake_file(f"https://example.test/tool-2.0-{upstream}")
|
||||
self.assertEqual(recipe["sha256sums_" + arch], [w.hash_file(file, "sha256")])
|
||||
self.assertFalse((self.package / "SHOULD-NOT-RUN").exists())
|
||||
|
||||
def test_missing_arm_payload_preserves_original_recipe(self):
|
||||
original = self.recipe.read_bytes()
|
||||
def fetch(url):
|
||||
if url.endswith('arm64'):
|
||||
raise ValueError("ARM release not published")
|
||||
return self.fake_file(url)
|
||||
with self.assertRaisesRegex(ValueError, "ARM"):
|
||||
self.sync_release(self.release(), fetch)
|
||||
self.assertEqual(self.recipe.read_bytes(), original)
|
||||
self.assertFalse(self.recipe.with_name('PKGBUILD.sync-upstream').exists())
|
||||
|
||||
def test_changed_checksum_override_is_detected_and_rolled_back(self):
|
||||
self.recipe.write_text(self.recipe.read_text() + "sha256sums_aarch64[0]='stale'\n")
|
||||
original = self.recipe.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, "differs"):
|
||||
self.sync_release(self.release())
|
||||
self.assertEqual(self.recipe.read_bytes(), original)
|
||||
|
||||
def test_old_or_equal_versions_do_not_download_sources(self):
|
||||
for version in ['0.9', '1.0']:
|
||||
with patch.object(w, 'discover', return_value=[self.release(version)]), patch.object(self.fetch, 'file') as fetch:
|
||||
self.assertEqual(w.sync(self.package, self.fetch)['status'], 'skipped')
|
||||
fetch.assert_not_called()
|
||||
|
||||
def test_manual_hold_prevents_release_discovery(self):
|
||||
path = self.package / '.omarchy/package.json'
|
||||
metadata = json.loads(path.read_text())
|
||||
metadata['sync'] = False
|
||||
path.write_text(json.dumps(metadata))
|
||||
with patch.object(w, 'discover') as discover:
|
||||
self.assertEqual(w.sync(self.package, self.fetch)['status'], 'skipped')
|
||||
discover.assert_not_called()
|
||||
|
||||
def test_epoch_change_in_recipe_cannot_mask_downgrade(self):
|
||||
self.recipe.write_text(self.recipe.read_text() + "if [[ $pkgver == 1.0 ]]; then epoch=2; else epoch=1; fi\n")
|
||||
original = self.recipe.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, "complete package version"):
|
||||
self.sync_release(self.release())
|
||||
self.assertEqual(self.recipe.read_bytes(), original)
|
||||
|
||||
def test_same_version_build_revision_advances_downstream_pkgrel(self):
|
||||
self.write_watch({'github': 'owner/tool', 'pattern': r'v(?P<version>[0-9.]+)-(?P<build>[0-9]+)', 'variables': {'_build': '{build}'}, 'revision': '{build}', 'revision_variable': '_build'})
|
||||
self.recipe.write_text(self.recipe.read_text().replace('pkgrel=3.1', 'pkgrel=3.1\n_build=8').replace('tool-$pkgver-', 'tool-$pkgver-$_build-'))
|
||||
release = self.release('1.0', build='9');release['revision'] = '9'
|
||||
self.assertEqual(self.sync_release(release)['after'], '0:1.0-3.2')
|
||||
self.assertEqual(w.scalar(w.read_recipe(self.recipe), '_build'), '9')
|
||||
release = self.release('1.0', build='7');release['revision'] = '7'
|
||||
original = self.recipe.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, 'without a newer'):
|
||||
self.sync_release(release)
|
||||
self.assertEqual(self.recipe.read_bytes(), original)
|
||||
|
||||
def test_sha512_blake2_and_local_hashes_are_preserved(self):
|
||||
self.recipe.write_text("""pkgver=1.0
|
||||
pkgrel=1
|
||||
arch=('any')
|
||||
source=("https://example.test/tool-$pkgver" 'fix.patch')
|
||||
sha512sums=('old' 'local-sha512')
|
||||
b2sums=('old' 'local-b2')
|
||||
""")
|
||||
(self.package / 'fix.patch').write_text('local patch')
|
||||
self.sync_release(self.release())
|
||||
recipe = w.read_recipe(self.recipe)
|
||||
file = self.fake_file('https://example.test/tool-2.0')
|
||||
self.assertEqual(recipe['sha512sums'], [w.hash_file(file, 'sha512'), 'local-sha512'])
|
||||
self.assertEqual(recipe['b2sums'], [w.hash_file(file, 'b2'), 'local-b2'])
|
||||
|
||||
def test_mutable_sources_are_rehashed_when_version_changes(self):
|
||||
self.write_watch({'regex': 'https://example.test/feed', 'pattern': r'(?P<version>[0-9.]+)', 'mutable_sources': ['source_x86_64:0']})
|
||||
self.recipe.write_text(self.recipe.read_text().replace('tool-$pkgver-x64','tool-current-x64'))
|
||||
self.sync_release(self.release())
|
||||
result = w.read_recipe(self.recipe)
|
||||
self.assertEqual(result['sha256sums_x86_64'], [w.hash_file(self.fake_file('https://example.test/tool-current-x64'), 'sha256')])
|
||||
|
||||
def test_release_selection_and_age(self):
|
||||
a, b = self.release('1.9'), self.release('1.10')
|
||||
self.assertEqual(w.select_release([a, b])['pkgver'], '1.10')
|
||||
b['published_at'] = '2 days ago'
|
||||
with self.assertRaisesRegex(ValueError, 'age'):
|
||||
w.select_release([b], 86400)
|
||||
b['published_at'] = '2999-01-01T00:00:00Z'
|
||||
self.assertIsNone(w.select_release([b], 86400))
|
||||
self.assertEqual(w.select_release([b], 86400, bypass=True), b)
|
||||
|
||||
def test_hostile_release_values_do_not_reach_shell(self):
|
||||
for value in ['1.0\ntouch bad', '$(touch bad)', '`id`', '1.0;id', '1.0"']:
|
||||
with self.assertRaises(ValueError):
|
||||
w.candidate({}, {'version': value})
|
||||
with self.assertRaises(ValueError):
|
||||
w.replace_scalar('pkgver=1.0\n', 'pkgver', '$(id)')
|
||||
for url in ['http://example.test', 'https://user:password@example.test/file', 'https://example.test/\nfile']:
|
||||
with self.assertRaises(ValueError):w.https(url)
|
||||
|
||||
def test_array_writer_handles_comments_and_quoted_parentheses(self):
|
||||
text = "sha256sums=( # closing ) in a comment\n 'old(value)'\n) # trailing\npackage() { :; }\n"
|
||||
output = w.replace_array(text, 'sha256sums', ['abc'])
|
||||
self.assertEqual(output, "sha256sums=('abc') # trailing\npackage() { :; }\n")
|
||||
|
||||
def test_git_hash_matches_makepkg(self):
|
||||
repo = self.root / 'git'
|
||||
repo.mkdir()
|
||||
subprocess.run(['git', 'init', '-q', str(repo)], check=True)
|
||||
(repo / 'source').write_text('upstream code')
|
||||
subprocess.run(['git', '-C', str(repo), 'add', '.'], check=True)
|
||||
subprocess.run(['git', '-C', str(repo), '-c', 'user.name=Test', '-c', 'user.email=test@example.test', 'commit', '-qm', 'release'], check=True)
|
||||
subprocess.run(['git', '-C', str(repo), 'tag', 'v1.0'], check=True)
|
||||
command = w.subprocess.run
|
||||
def redirect(args, **kwargs):
|
||||
if 'fetch' in args:
|
||||
args = [str(repo) if arg == 'https://example.test/tool.git' else arg for arg in args]
|
||||
return command(args, **kwargs)
|
||||
with patch.object(w.subprocess, 'run', side_effect=redirect):
|
||||
archive = w.git_source_file('git+https://example.test/tool.git#tag=v1.0', self.fetch.cache)
|
||||
expected = subprocess.check_output(['git', '-c', 'core.abbrev=no', '-C', str(repo), 'archive', '--format', 'tar', 'v1.0'])
|
||||
self.assertEqual(archive.read_bytes(), expected)
|
||||
|
||||
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'}},
|
||||
{'allow_prerelease': 'false'}, {'sequence': 1}, {'mutable_sources': ['source:no']},
|
||||
{'revision_variable': '_build'}, {'submodules': {'_commit': '../outside'}},
|
||||
{'allow_prerelase': True}]:
|
||||
with self.subTest(extra=extra), self.assertRaises(ValueError):
|
||||
w.validate({**valid, **extra})
|
||||
with self.assertRaisesRegex(ValueError, 'numeric'):
|
||||
w.candidate({'revision': '{build}'}, {'version': '1.0', 'build': 'beta'})
|
||||
|
||||
def test_github_selects_stable_matching_tag_and_resolves_gitlinks(self):
|
||||
watch = {'github': 'owner/tool', 'pattern': r'v(?P<version>[0-9.]+)',
|
||||
'variables': {'_commit': '{commit}'}, 'submodules': {'_common': 'libs/common'}}
|
||||
def fetch(url):
|
||||
if '/releases?' in url:
|
||||
return [{'tag_name': tag, 'published_at': '2026-01-01T00:00:00Z', **flags}
|
||||
for tag, flags in [('v1.0', {}), ('v2.0', {}), ('v3.0', {'prerelease': True}),
|
||||
('v4.0', {'draft': True}), ('unrelated-v5.0', {})]]
|
||||
if '/git/ref/' in url: return {'object': {'type': 'tag', 'sha': 'a' * 40}}
|
||||
if '/git/tags/' in url: return {'object': {'type': 'commit', 'sha': 'b' * 40}}
|
||||
if '/contents/' in url:
|
||||
self.assertTrue(url.endswith('?ref=v2.0'))
|
||||
return {'sha': 'c' * 40, 'submodule_git_url': 'https://example.test/common'}
|
||||
self.fail(url)
|
||||
with patch.object(self.fetch, 'json', side_effect=fetch):
|
||||
release = w.select_release(w.discover(watch, self.fetch))
|
||||
self.assertEqual(release['pkgver'], '2.0')
|
||||
self.assertEqual(w.resolve_release_fields(watch, release, self.fetch)['variables'],
|
||||
{'_commit': 'b' * 40, '_common': 'c' * 40})
|
||||
|
||||
def test_vendor_feeds_preserve_version_schemes(self):
|
||||
cases = [
|
||||
('1password-beta', 'Package: unrelated\nVersion: 99.0\n\nPackage: 1password\nVersion: 8.12.38~25.BETA\n', '8.12.38_25.BETA'),
|
||||
('spotify', 'Package: spotify-client\nVersion: 1:1.2.96.518.g366879e1\n', '1.2.96.518'),
|
||||
('typora', 'Package: typora\nVersion: 1.12.6-1\n\nPackage: typora\nVersion: 1.9.0-1\n', '1.12.6'),
|
||||
('nvidia-580xx-utils', '<a href="580.159.04/">580.159.04/</a> <a href="590.1/">590.1/</a>', '580.159.04'),
|
||||
]
|
||||
for name, feed, expected in cases:
|
||||
watch = json.loads((ROOT / 'pkgbuilds' / name / '.omarchy/package.json').read_text())['upstream']['watch']
|
||||
with self.subTest(package=name), patch.object(self.fetch, 'text', return_value=feed):
|
||||
self.assertEqual(w.select_release(w.discover(watch, self.fetch))['pkgver'], expected)
|
||||
with patch.object(self.fetch, 'json', return_value={'release': {'version': '2.0', 'commit': 'a' * 40}}):
|
||||
release = w.discover({'json': 'https://example.test/feed', 'path': 'release.version', 'fields': {'commit': 'release.commit'}}, self.fetch)[0]
|
||||
self.assertEqual(release['values']['commit'], 'a' * 40)
|
||||
|
||||
def test_registry_releases_and_archive_filenames(self):
|
||||
with patch.object(self.fetch, 'json', return_value={'dist-tags': {'latest': '2.0'}, 'time': {'2.0': '2026-01-01T00:00:00Z'}}):
|
||||
self.assertEqual(w.discover({'npm': '@owner/tool'}, self.fetch)[0]['pkgver'], '2.0')
|
||||
with patch.object(self.fetch, 'json', return_value={'info': {'version': '2.0'}, 'releases': {'2.0': [{'yanked': True}]}}):
|
||||
with self.assertRaisesRegex(ValueError, 'unyanked'):
|
||||
w.discover({'pypi': 'tool'}, self.fetch)
|
||||
archive = self.root / 'vendor.zip'
|
||||
with zipfile.ZipFile(archive, 'w') as z:
|
||||
z.writestr('driver/yt6801-1.0.34.tar.gz', 'not executed or extracted')
|
||||
watch = json.loads((ROOT / 'pkgbuilds/yt6801-dkms/.omarchy/package.json').read_text())['upstream']['watch']
|
||||
with patch.object(self.fetch, 'file', return_value=archive):
|
||||
self.assertEqual(w.discover(watch, self.fetch)[0]['pkgver'], '1.0.34')
|
||||
|
||||
def test_cursor_same_day_hash_uses_counter_not_hash_order(self):
|
||||
self.write_watch({'regex': 'https://example.test/feed', 'pattern': r'(?P<version>[0-9.]+)-(?P<hash>[a-f0-9]+)',
|
||||
'version': '{version}.1.{hash}', 'sequence': True})
|
||||
self.recipe.write_text(self.recipe.read_text().replace('pkgver=1.0', 'pkgver=2026.09.14.1.ffff'))
|
||||
release = w.candidate({'version': '{version}.1.{hash}'}, {'version': '2026.09.14', 'hash': 'aaaa'})
|
||||
self.assertEqual(self.sync_release(release)['after'], '0:2026.09.14.2.aaaa-1')
|
||||
self.assertEqual(self.sync_release(release)['status'], 'skipped')
|
||||
|
||||
def test_existing_signed_metadata_skips_are_retained(self):
|
||||
self.recipe.write_text(self.recipe.read_text().replace("'old-arm64'", "'SKIP'") + "sha256sums_aarch64[0]='SKIP'\n")
|
||||
self.sync_release(self.release())
|
||||
self.assertEqual(w.read_recipe(self.recipe)['sha256sums_aarch64'], ['SKIP'])
|
||||
self.assertTrue((self.root / 'source-tool-2.0-arm64').exists())
|
||||
|
||||
def test_conditional_arm_source_cannot_use_x86_hash(self):
|
||||
self.recipe.write_text(self.recipe.read_text() + 'if [[ $CARCH == aarch64 ]]; then source_aarch64=("https://example.test/different-$pkgver"); fi\n')
|
||||
original = self.recipe.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, 'conditional source'):
|
||||
self.sync_release(self.release())
|
||||
self.assertEqual(self.recipe.read_bytes(), original)
|
||||
|
||||
def test_cli_continues_after_one_package_fails(self):
|
||||
for directory in ['bin', 'helpers']:
|
||||
shutil.copytree(ROOT / directory, self.root / directory)
|
||||
for name in ['bad', 'good', 'held']:
|
||||
pkg = self.root / 'pkgbuilds' / name
|
||||
shutil.copytree(self.package, pkg)
|
||||
(pkg / '.omarchy/package.json').write_text(json.dumps({'source': 'local', 'sync': name != 'held', 'upstream': {'watch': {
|
||||
'regex': 'https://example.test/feed', 'pattern': '(?P<version>[0-9.]+)'}}}))
|
||||
path = pkg / 'PKGBUILD'
|
||||
path.write_text(path.read_text().replace('example.test/tool-', f'example.test/{name}-'))
|
||||
stub = self.root / 'stub'
|
||||
stub.mkdir()
|
||||
curl = stub / 'curl'
|
||||
curl.write_text('''#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
url = sys.argv[-1]
|
||||
if 'bad-2.0-arm64' in url: sys.exit(22)
|
||||
Path(sys.argv[sys.argv.index('-o') + 1]).write_text('2.0' if url.endswith('/feed') else url)
|
||||
''')
|
||||
curl.chmod(0o755)
|
||||
bad = self.root / 'pkgbuilds/bad/PKGBUILD'
|
||||
original = bad.read_bytes()
|
||||
result = subprocess.run([str(self.root / 'bin/sync-upstream'), 'bad', 'good', 'held'],
|
||||
env={**os.environ, 'PATH': str(stub) + os.pathsep + os.environ['PATH']},
|
||||
text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertEqual(bad.read_bytes(), original)
|
||||
self.assertEqual(w.scalar(w.read_recipe(self.root / 'pkgbuilds/good/PKGBUILD'), 'pkgver'), '2.0')
|
||||
self.assertEqual(w.scalar(w.read_recipe(self.root / 'pkgbuilds/held/PKGBUILD'), 'pkgver'), '1.0')
|
||||
self.assertIn('Updated: 1', result.stdout)
|
||||
self.assertIn('Failed: 1', result.stdout)
|
||||
|
||||
def test_aur_import_is_once_only_and_records_local_ownership(self):
|
||||
for directory in ['bin', 'helpers']:
|
||||
shutil.copytree(ROOT / directory, self.root / directory)
|
||||
repo = self.root / 'aur'
|
||||
repo.mkdir()
|
||||
subprocess.run(['git', 'init', '-q', str(repo)], check=True)
|
||||
(repo / 'PKGBUILD').write_text('pkgver=1.0\npkgrel=1\ntouch SHOULD-NOT-RUN\n')
|
||||
(repo / '.SRCINFO').write_text('AUR only')
|
||||
subprocess.run(['git', '-C', str(repo), 'add', '.'], check=True)
|
||||
subprocess.run(['git', '-C', str(repo), '-c', 'user.name=Test', '-c', 'user.email=test@example.test', 'commit', '-qm', 'recipe'], check=True)
|
||||
stub = self.root / 'stub'
|
||||
stub.mkdir()
|
||||
git = stub / 'git'
|
||||
git.write_text('''#!/usr/bin/env python3
|
||||
import os, sys
|
||||
args = [os.environ['AUR_FIXTURE'] if v == 'https://aur.archlinux.org/upstream-name.git' else v for v 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'], 'AUR_FIXTURE': str(repo), 'REAL_GIT': shutil.which('git')}
|
||||
command = [str(self.root / 'bin/add-package'), 'local-name', '--source', 'aur', '--aur', 'upstream-name', '--no-sync', '--fast']
|
||||
result = subprocess.run(command, env=env, text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
package = self.root / 'pkgbuilds/local-name'
|
||||
metadata_file = package / '.omarchy/package.json'
|
||||
metadata = json.loads(metadata_file.read_text())
|
||||
self.assertEqual(metadata['source'], 'local')
|
||||
self.assertEqual(metadata['origin']['aur'], 'upstream-name')
|
||||
self.assertEqual(metadata['origin']['commit'], subprocess.check_output(['git', '-C', str(repo), 'rev-parse', 'HEAD'], text=True).strip())
|
||||
self.assertFalse(metadata['sync'])
|
||||
self.assertEqual(metadata['release_ring'], 'fast')
|
||||
self.assertFalse((package / '.SRCINFO').exists())
|
||||
self.assertFalse((package / 'SHOULD-NOT-RUN').exists())
|
||||
original = metadata_file.read_bytes()
|
||||
result = subprocess.run([*command, '--force'], env=env, text=True, capture_output=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(metadata_file.read_bytes(), original)
|
||||
|
||||
|
||||
class T3CodeHookTest(unittest.TestCase):
|
||||
"""Keep both desktop architectures on the same complete upstream release."""
|
||||
|
||||
def setUp(self):
|
||||
work = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(work.cleanup)
|
||||
self.root = Path(work.name)
|
||||
self.recipe = self.root / "PKGBUILD"
|
||||
self.recipe.write_text("pkgver=0.0.41\n")
|
||||
self.feed = self.root / "latest-linux.yml"
|
||||
self.feed.write_text("version: 0.0.42\npath: T3-Code-0.0.42-x86_64.AppImage\n")
|
||||
self.arm_feed = self.root / "latest-linux-arm64.yml"
|
||||
self.arm_feed.write_text("version: 0.0.42\npath: T3-Code-0.0.42-arm64.AppImage\n")
|
||||
for arch in ("x86_64", "arm64"):
|
||||
(self.root / f"T3-Code-0.0.42-{arch}.AppImage").write_text(arch)
|
||||
# Serve only fixture assets, and record the requested release URLs.
|
||||
curl = self.root / "curl"
|
||||
curl.write_text('#!/bin/bash\nurl="${@: -1}"\nprintf "%s\\n" "$url" >> requests\ncat "${url##*/}"\n')
|
||||
curl.chmod(0o755)
|
||||
self.env = dict(os.environ, PATH=f"{self.root}:{os.environ['PATH']}")
|
||||
|
||||
def run_hook(self):
|
||||
return subprocess.run(
|
||||
['bash', str(ROOT / 'pkgbuilds/t3code-bin/.omarchy/upstream.sh')],
|
||||
cwd=self.root, env=self.env, text=True, capture_output=True,
|
||||
)
|
||||
|
||||
def test_hashes_both_architectures_from_one_release(self):
|
||||
result = self.run_hook()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout), {
|
||||
'pkgver': '0.0.42',
|
||||
'sha256sums': {
|
||||
arch: [w.hash_file(self.root / f'T3-Code-0.0.42-{asset_arch}.AppImage', 'sha256')]
|
||||
for arch, asset_arch in [('x86_64', 'x86_64'), ('aarch64', 'arm64')]
|
||||
},
|
||||
})
|
||||
self.assertIn('/download/v0.0.42/latest-linux-arm64.yml', (self.root / 'requests').read_text())
|
||||
|
||||
def test_current_version_does_not_download_assets(self):
|
||||
self.recipe.write_text('pkgver=0.0.42\n')
|
||||
result = self.run_hook()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout), {})
|
||||
self.assertEqual(len((self.root / 'requests').read_text().splitlines()), 1)
|
||||
|
||||
def test_incomplete_or_mismatched_arm_release_reports_no_update(self):
|
||||
for bad_feed in ('', 'version: 0.0.43\npath: T3-Code-0.0.42-arm64.AppImage\n',
|
||||
'version: 0.0.42\npath: renamed.AppImage\n'):
|
||||
with self.subTest(feed=bad_feed):
|
||||
self.arm_feed.write_text(bad_feed)
|
||||
result = self.run_hook()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout, '')
|
||||
self.arm_feed.unlink()
|
||||
result = self.run_hook()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout, '')
|
||||
|
||||
def test_missing_arm_asset_reports_no_update(self):
|
||||
(self.root / 'T3-Code-0.0.42-arm64.AppImage').unlink()
|
||||
result = self.run_hook()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout, '')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
Reference in new issue
Block a user