mirror of
https://github.com/Cheviiot/Nivora.git
synced 2026-08-03 15:51:12 +00:00
feat: create autonomous Nivora package repository
Maintain a validated cross-distribution package catalog. Automate upstream updates, isolated builds, diagnostics, and direct publication. Build the official GitHub Desktop sources for Linux with working OAuth.
This commit is contained in:
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
set -uo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||
results_dir="${AUTONOMOUS_UPDATE_RESULTS_DIR:?AUTONOMOUS_UPDATE_RESULTS_DIR is required}"
|
||||
|
||||
[[ "$#" -gt 0 ]] || {
|
||||
echo 'usage: autonomous_package_updates.sh package...' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
mkdir -p "$results_dir"
|
||||
: >"${results_dir}/successful-packages"
|
||||
: >"${results_dir}/failed-packages"
|
||||
|
||||
for package in "$@"; do
|
||||
package_result="${results_dir}/${package}"
|
||||
worktree="${RUNNER_TEMP:?RUNNER_TEMP is required}/update-${package}"
|
||||
mkdir -p "$package_result"
|
||||
|
||||
if ! git -C "$repo_root" worktree add --detach "$worktree" HEAD \
|
||||
>"${package_result}/worktree.log" 2>&1; then
|
||||
{
|
||||
printf 'package=%s\n' "$package"
|
||||
printf 'phase=prepare-worktree\n'
|
||||
printf 'exit_status=1\n'
|
||||
} >"${package_result}/FAILED"
|
||||
printf '%s\t%s\n' "$package" prepare-worktree \
|
||||
>>"${results_dir}/failed-packages"
|
||||
echo failure >"${package_result}/result"
|
||||
continue
|
||||
fi
|
||||
|
||||
phase_file="${package_result}/phase"
|
||||
log_file="${package_result}/update.log"
|
||||
if (
|
||||
set -euo pipefail
|
||||
cd "$worktree"
|
||||
|
||||
echo update-recipe >"$phase_file"
|
||||
stplr-spec update-package "$package"
|
||||
|
||||
echo sync-catalog >"$phase_file"
|
||||
tools/sync_readme_versions.py
|
||||
|
||||
echo static-checks >"$phase_file"
|
||||
tools/run_checks.sh
|
||||
|
||||
echo clean-build >"$phase_file"
|
||||
tools/clean_build.sh "$package"
|
||||
|
||||
echo verify-artifact >"$phase_file"
|
||||
tools/verify_artifacts.sh "$package"
|
||||
) >"$log_file" 2>&1; then
|
||||
git -C "$worktree" diff --binary -- "$package" \
|
||||
>"${package_result}/update.patch"
|
||||
printf '%s\n' "$package" >>"${results_dir}/successful-packages"
|
||||
echo success >"${package_result}/result"
|
||||
else
|
||||
status=$?
|
||||
failed_phase="$(<"$phase_file")"
|
||||
{
|
||||
printf 'package=%s\n' "$package"
|
||||
printf 'phase=%s\n' "$failed_phase"
|
||||
printf 'exit_status=%s\n' "$status"
|
||||
} >"${package_result}/FAILED"
|
||||
git -C "$worktree" diff --binary \
|
||||
>"${package_result}/failed.patch"
|
||||
if [[ -f "${worktree}/${package}/Staplerfile" ]]; then
|
||||
install -Dm644 "${worktree}/${package}/Staplerfile" \
|
||||
"${package_result}/Staplerfile.after"
|
||||
fi
|
||||
printf '%s\t%s\n' "$package" "$failed_phase" \
|
||||
>>"${results_dir}/failed-packages"
|
||||
echo failure >"${package_result}/result"
|
||||
fi
|
||||
|
||||
git -C "$repo_root" worktree remove --force "$worktree" \
|
||||
>>"${package_result}/worktree.log" 2>&1
|
||||
done
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||
# shellcheck source=tools/lib/source_cache.sh
|
||||
source "${script_dir}/lib/source_cache.sh"
|
||||
cd "$repo_root"
|
||||
|
||||
mapfile -t all_packages < <(
|
||||
for staplerfile in */Staplerfile; do
|
||||
dirname "$staplerfile"
|
||||
done | sort
|
||||
)
|
||||
|
||||
is_package() {
|
||||
local requested="$1"
|
||||
local package
|
||||
for package in "${all_packages[@]}"; do
|
||||
[[ "$requested" == "$package" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
packages=()
|
||||
if [[ "${1:-}" == '--all' ]]; then
|
||||
packages=("${all_packages[@]}")
|
||||
shift
|
||||
else
|
||||
packages=("$@")
|
||||
fi
|
||||
|
||||
[[ "${#packages[@]}" -gt 0 ]] || {
|
||||
echo 'Usage: tools/clean_build.sh {--all|package...}' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
for package in "${packages[@]}"; do
|
||||
is_package "$package" || {
|
||||
echo "Unknown package: ${package}" >&2
|
||||
exit 2
|
||||
}
|
||||
done
|
||||
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
engine=podman
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
engine=docker
|
||||
else
|
||||
echo 'clean-build requires stplr-spec with clean-build, Podman or Docker' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
readonly image='registry.altlinux.org/sisyphus/base:latest'
|
||||
readonly cache_volume="nivora-clean-build-cache-$$"
|
||||
readonly builder_image="nivora-clean-build:$$"
|
||||
builder_dir="$(mktemp -d)"
|
||||
docker_config=''
|
||||
source_cache="${NIVORA_SOURCE_CACHE:-${XDG_CACHE_HOME:-${HOME}/.cache}/stplr/dl}"
|
||||
|
||||
cleanup() {
|
||||
"$engine" volume rm "$cache_volume" >/dev/null 2>&1 || true
|
||||
"$engine" image rm "$builder_image" >/dev/null 2>&1 || true
|
||||
find "$builder_dir" -mindepth 1 -delete
|
||||
rmdir "$builder_dir"
|
||||
if [[ -n "$docker_config" ]]; then
|
||||
find "$docker_config" -mindepth 1 -delete
|
||||
rmdir "$docker_config"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ "$engine" == docker ]]; then
|
||||
docker_config="$(mktemp -d)"
|
||||
printf '{}\n' >"${docker_config}/config.json"
|
||||
export DOCKER_CONFIG="$docker_config"
|
||||
fi
|
||||
|
||||
cat >"${builder_dir}/Containerfile" <<EOF
|
||||
FROM ${image}
|
||||
RUN for attempt in 1 2 3; do \
|
||||
apt-get update \
|
||||
&& apt-get dist-upgrade -y \
|
||||
&& apt-get install -y ca-certificates stplr binutils python3 \
|
||||
&& exit 0; \
|
||||
sleep 5; \
|
||||
done; \
|
||||
exit 1
|
||||
EOF
|
||||
|
||||
pulled=0
|
||||
for attempt in 1 2 3; do
|
||||
if "$engine" pull "$image"; then
|
||||
pulled=1
|
||||
break
|
||||
fi
|
||||
sleep "$((attempt * 5))"
|
||||
done
|
||||
[[ "$pulled" -eq 1 ]] || exit 1
|
||||
"$engine" volume create "$cache_volume" >/dev/null
|
||||
"$engine" build -t "$builder_image" -f "${builder_dir}/Containerfile" "$builder_dir"
|
||||
|
||||
import_stplr_source_cache \
|
||||
"$engine" "$builder_image" "$cache_volume" "$builder_dir" "$source_cache" \
|
||||
"${packages[@]}"
|
||||
|
||||
for package in "${packages[@]}"; do
|
||||
echo "==> clean-build ${package} (${engine})"
|
||||
find "$package" -maxdepth 1 -type f \
|
||||
\( -name '*.rpm' -o -name '*.deb' -o -name '*.apk' -o -name '*.pkg.tar.*' \) \
|
||||
-delete
|
||||
built=0
|
||||
for attempt in 1 2; do
|
||||
if "$engine" run --rm --privileged \
|
||||
-e TERM=xterm-256color \
|
||||
-v "${repo_root}/${package}:/app" \
|
||||
-v "${cache_volume}:/var/cache/stplr" \
|
||||
-w /app \
|
||||
"$builder_image" \
|
||||
stplr --interactive=false build --clean -s Staplerfile; then
|
||||
built=1
|
||||
break
|
||||
fi
|
||||
echo "==> retry ${package} (${attempt}/2)" >&2
|
||||
sleep "$((attempt * 5))"
|
||||
done
|
||||
[[ "$built" -eq 1 ]] || exit 1
|
||||
done
|
||||
|
||||
echo "OK: clean-build завершён для ${#packages[@]} пакетов"
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
|
||||
import_stplr_source_cache() {
|
||||
local cache_engine="$1"
|
||||
local cache_image="$2"
|
||||
local cache_volume_name="$3"
|
||||
local cache_work_dir="$4"
|
||||
local cache_source_dir="$5"
|
||||
shift 5
|
||||
local packages=("$@")
|
||||
|
||||
if [[ ! -d "$cache_source_dir" ]] || ! command -v sqlite3 >/dev/null 2>&1; then
|
||||
echo '==> source cache import skipped'
|
||||
return 0
|
||||
fi
|
||||
|
||||
local cache_manifest="${cache_work_dir}/source-cache.tsv"
|
||||
local cache_db="${cache_work_dir}/source-cache.db"
|
||||
local package package_name package_version index source expected
|
||||
local url_hash restore_name candidate actual
|
||||
local -a package_sources package_checksums
|
||||
|
||||
: >"$cache_manifest"
|
||||
sqlite3 "$cache_db" '
|
||||
CREATE TABLE cache_record (
|
||||
i_d INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
hash TEXT NULL,
|
||||
repo TEXT NULL,
|
||||
pkg TEXT NULL,
|
||||
ver TEXT NULL,
|
||||
name TEXT NULL,
|
||||
type INTEGER NULL
|
||||
);
|
||||
CREATE INDEX IDX_cache_record_hash ON cache_record(hash);
|
||||
CREATE INDEX IDX_cache_record_repo ON cache_record(repo);
|
||||
CREATE INDEX IDX_cache_record_pkg ON cache_record(pkg);
|
||||
CREATE INDEX IDX_cache_record_ver ON cache_record(ver);
|
||||
'
|
||||
|
||||
for package in "${packages[@]}"; do
|
||||
package_name="$(stplr-spec get-field --path "${package}/Staplerfile" name)"
|
||||
package_version="$(stplr-spec get-field --path "${package}/Staplerfile" version)"
|
||||
[[ "$package_name" =~ ^[a-z0-9.+-]+$ ]]
|
||||
[[ "$package_version" =~ ^[A-Za-z0-9._+~-]+$ ]]
|
||||
|
||||
read -ra package_sources <<<"$(
|
||||
stplr-spec get-field --path "${package}/Staplerfile" sources
|
||||
)"
|
||||
read -ra package_checksums <<<"$(
|
||||
stplr-spec get-field --path "${package}/Staplerfile" checksums
|
||||
)"
|
||||
|
||||
for index in "${!package_sources[@]}"; do
|
||||
source="${package_sources[$index]}"
|
||||
[[ "$source" != local://* && "$source" == *'~archive=false'* ]] || continue
|
||||
[[ -v 'package_checksums[index]' ]]
|
||||
expected="${package_checksums[$index]#sha256:}"
|
||||
url_hash="$(printf '%s' "$source" | sha256sum)"
|
||||
url_hash="${url_hash%% *}"
|
||||
if [[ "$source" == *'~name='* ]]; then
|
||||
restore_name="${source##*~name=}"
|
||||
restore_name="${restore_name%%&*}"
|
||||
else
|
||||
restore_name="$(basename "${source%%\?*}")"
|
||||
fi
|
||||
[[ "$restore_name" =~ ^[A-Za-z0-9._+-]+$ ]]
|
||||
|
||||
candidate="${cache_source_dir}/${url_hash}/${restore_name}"
|
||||
[[ -f "$candidate" ]] || continue
|
||||
actual="$(sha256sum "$candidate")"
|
||||
actual="${actual%% *}"
|
||||
[[ "$actual" == "$expected" ]] || continue
|
||||
|
||||
printf '%s\t%s\t%s\n' "$url_hash" "$restore_name" "$expected" \
|
||||
>>"$cache_manifest"
|
||||
sqlite3 "$cache_db" \
|
||||
"INSERT INTO cache_record(hash, repo, pkg, ver, name, type) VALUES('$url_hash', 'default', '$package_name', '$package_version', '$restore_name', 1);"
|
||||
done
|
||||
done
|
||||
|
||||
echo "==> importing verified sources from ${cache_source_dir}"
|
||||
# The script is intentionally expanded inside the container, not by the host shell.
|
||||
# shellcheck disable=SC2016
|
||||
"$cache_engine" run --rm \
|
||||
-v "${cache_source_dir}:/source:ro" \
|
||||
-v "${cache_manifest}:/manifest:ro" \
|
||||
-v "${cache_db}:/cache-db:ro" \
|
||||
-v "${cache_volume_name}:/var/cache/stplr" \
|
||||
"$cache_image" \
|
||||
bash -euo pipefail -c '
|
||||
mkdir -p /var/cache/stplr/dl
|
||||
cp -a /cache-db /var/cache/stplr/dl/db
|
||||
imported=0
|
||||
bytes=0
|
||||
while IFS=$'"'"'\t'"'"' read -r url_hash restore_name expected; do
|
||||
source="/source/${url_hash}/${restore_name}"
|
||||
actual="$(sha256sum "$source")"
|
||||
actual="${actual%% *}"
|
||||
[[ "$actual" == "$expected" ]]
|
||||
destination="/var/cache/stplr/dl/${url_hash}/${restore_name}"
|
||||
mkdir -p "${destination%/*}"
|
||||
cp -a "$source" "$destination"
|
||||
size="$(stat -c %s "$source")"
|
||||
imported=$((imported + 1))
|
||||
bytes=$((bytes + size))
|
||||
done </manifest
|
||||
chown -R --reference=/var/cache/stplr /var/cache/stplr/dl
|
||||
printf "Imported sources: %d (%d bytes)\n" "$imported" "$bytes"
|
||||
'
|
||||
}
|
||||
Executable
+294
@@ -0,0 +1,294 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
readonly -a PACKAGES=(
|
||||
adwyra
|
||||
anidesk
|
||||
balena-etcher
|
||||
chatbox
|
||||
clash-verge-rev
|
||||
claude-desktop
|
||||
codex
|
||||
fisher
|
||||
github-desktop
|
||||
happ
|
||||
netbird
|
||||
nivora-stplr
|
||||
opencode
|
||||
parsec
|
||||
pineconemc
|
||||
tailscale
|
||||
ventoy
|
||||
vual
|
||||
yandex-browser-stable
|
||||
)
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||
|
||||
die() {
|
||||
echo "package_updates: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_package() {
|
||||
local requested="$1"
|
||||
local package
|
||||
for package in "${PACKAGES[@]}"; do
|
||||
[[ "$package" == "$requested" ]] && return 0
|
||||
done
|
||||
die "unknown package: ${requested}"
|
||||
}
|
||||
|
||||
current_version() {
|
||||
stplr-spec get-field --path "${repo_root}/$1/Staplerfile" version
|
||||
}
|
||||
|
||||
github_json() {
|
||||
local url="$1"
|
||||
local -a headers=(
|
||||
-H 'Accept: application/vnd.github+json'
|
||||
-H 'X-GitHub-Api-Version: 2022-11-28'
|
||||
)
|
||||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||||
headers+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
|
||||
fi
|
||||
curl --retry 3 --retry-delay 2 --retry-all-errors \
|
||||
--connect-timeout 30 --max-time 120 -fsSL "${headers[@]}" "$url"
|
||||
}
|
||||
|
||||
git_latest_stable_tag() {
|
||||
local repository="$1"
|
||||
local attempt tag=''
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
tag="$(
|
||||
GIT_TERMINAL_PROMPT=0 timeout 60s \
|
||||
git ls-remote --tags --refs "https://github.com/${repository}.git" 'refs/tags/v*' |
|
||||
awk '{sub("refs/tags/", "", $2); print $2}' |
|
||||
grep -Eiv '(^|[-_.])(alpha|beta|rc|pre|preview)([-_.0-9]|$)' |
|
||||
sort -V |
|
||||
tail -1
|
||||
)" || tag=''
|
||||
if [[ -n "$tag" ]]; then
|
||||
printf '%s\n' "$tag"
|
||||
return 0
|
||||
fi
|
||||
sleep "$((attempt * 2))"
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
github_latest_release() {
|
||||
local repository="$1"
|
||||
local tag=''
|
||||
tag="$(
|
||||
github_json "https://api.github.com/repos/${repository}/releases?per_page=30" |
|
||||
jq -r '
|
||||
[
|
||||
.[]
|
||||
| select(.draft == false and .prerelease == false)
|
||||
| .tag_name
|
||||
| select(test("(?:^|[-_.])(alpha|beta|rc|pre|preview)(?:[-_.0-9]|$)"; "i") | not)
|
||||
][0] // empty
|
||||
' 2>/dev/null
|
||||
)" || tag=''
|
||||
|
||||
if [[ -z "$tag" ]]; then
|
||||
tag="$(git_latest_stable_tag "$repository")" || tag=''
|
||||
fi
|
||||
|
||||
[[ -n "$tag" ]] || {
|
||||
echo "package_updates: cannot determine latest release for ${repository}" >&2
|
||||
return 1
|
||||
}
|
||||
printf '%s\n' "${tag#v}"
|
||||
}
|
||||
|
||||
latest_anidesk() {
|
||||
local version
|
||||
version="$(
|
||||
GIT_TERMINAL_PROMPT=0 timeout 60s \
|
||||
git ls-remote --tags --refs https://github.com/theDesConnet/AniDesk.git 'refs/tags/v*' |
|
||||
awk '{sub("refs/tags/v", "", $2); print $2}' |
|
||||
sort -V |
|
||||
tail -1
|
||||
)"
|
||||
[[ -n "$version" ]] || die 'cannot determine latest AniDesk version'
|
||||
printf '%s\n' "$version"
|
||||
}
|
||||
|
||||
latest_chatbox() {
|
||||
local version
|
||||
version="$(
|
||||
github_json 'https://api.github.com/repos/chatboxai/chatbox/releases?per_page=30' |
|
||||
jq -er '
|
||||
[
|
||||
.[]
|
||||
| select(.draft == false and .prerelease == false)
|
||||
| . as $release
|
||||
| $release.assets[]?
|
||||
| select(.name | test("^Chatbox-[0-9]+(?:\\.[0-9]+)+-amd64\\.deb$"))
|
||||
| $release.tag_name
|
||||
][0]
|
||||
' |
|
||||
sed 's/^v//'
|
||||
)" || die 'cannot determine latest Chatbox Linux release'
|
||||
printf '%s\n' "$version"
|
||||
}
|
||||
|
||||
latest_claude_desktop() {
|
||||
local version
|
||||
version="$(
|
||||
curl --retry 3 --retry-delay 2 --retry-all-errors \
|
||||
--connect-timeout 30 --max-time 120 -fsSL \
|
||||
'https://downloads.claude.ai/claude-desktop/apt/stable/dists/stable/main/binary-amd64/Packages' |
|
||||
awk '
|
||||
/^Package: claude-desktop$/ { selected = 1; next }
|
||||
/^Package: / { selected = 0 }
|
||||
selected && /^Version: / { print $2 }
|
||||
' |
|
||||
sort -V |
|
||||
tail -1
|
||||
)"
|
||||
[[ -n "$version" ]] || die 'cannot determine latest Claude Desktop version'
|
||||
printf '%s\n' "$version"
|
||||
}
|
||||
|
||||
latest_parsec() {
|
||||
local temp_dir version
|
||||
temp_dir="$(mktemp -d)"
|
||||
curl --retry 3 --retry-delay 2 --retry-all-errors \
|
||||
--connect-timeout 30 --max-time 300 -fsSL \
|
||||
-o "${temp_dir}/parsec.deb" \
|
||||
'https://builds.parsec.app/package/parsec-linux.deb'
|
||||
(
|
||||
cd "$temp_dir"
|
||||
ar x parsec.deb
|
||||
tar -xOf control.tar.* ./control
|
||||
) >"${temp_dir}/control"
|
||||
version="$(awk '$1 == "Version:" {print $2; exit}' "${temp_dir}/control")"
|
||||
[[ -n "$version" ]] || die 'cannot determine latest Parsec version'
|
||||
printf '%s\n' "$version"
|
||||
find "$temp_dir" -mindepth 1 -delete
|
||||
rmdir "$temp_dir"
|
||||
}
|
||||
|
||||
latest_tailscale() {
|
||||
local effective version
|
||||
effective="$(
|
||||
curl --retry 3 --retry-delay 2 --retry-all-errors \
|
||||
--connect-timeout 30 --max-time 120 -fsSLI -o /dev/null \
|
||||
-w '%{url_effective}' \
|
||||
'https://pkgs.tailscale.com/stable/tailscale_latest_amd64.tgz'
|
||||
)"
|
||||
version="$(sed -n 's/.*tailscale_\([0-9][0-9.]*\)_amd64\.tgz.*/\1/p' <<<"$effective")"
|
||||
[[ -n "$version" ]] || die 'cannot determine latest Tailscale version'
|
||||
printf '%s\n' "$version"
|
||||
}
|
||||
|
||||
latest_yandex_browser() {
|
||||
local version
|
||||
version="$(
|
||||
curl --retry 3 --retry-delay 2 --retry-all-errors \
|
||||
--connect-timeout 30 --max-time 120 -fsSL \
|
||||
'https://repo.yandex.ru/yandex-browser/deb/dists/stable/main/binary-amd64/Packages.gz' |
|
||||
gzip -dc |
|
||||
awk '
|
||||
/^Package: yandex-browser-stable$/ { selected = 1; next }
|
||||
/^Package: / { selected = 0 }
|
||||
selected && /^Version: / {
|
||||
version = $2
|
||||
sub(/-[^-]+$/, "", version)
|
||||
print version
|
||||
}
|
||||
' |
|
||||
sort -V |
|
||||
tail -1
|
||||
)"
|
||||
[[ -n "$version" ]] || die 'cannot determine latest Yandex Browser version'
|
||||
printf '%s\n' "$version"
|
||||
}
|
||||
|
||||
latest_version() {
|
||||
case "$1" in
|
||||
adwyra) github_latest_release Cheviiot/Adwyra ;;
|
||||
anidesk) latest_anidesk ;;
|
||||
balena-etcher) github_latest_release balena-io/etcher ;;
|
||||
chatbox) latest_chatbox ;;
|
||||
clash-verge-rev) github_latest_release clash-verge-rev/clash-verge-rev ;;
|
||||
claude-desktop) latest_claude_desktop ;;
|
||||
codex) github_latest_release Boria138/codex-app-linux ;;
|
||||
fisher) github_latest_release jorgebucaran/fisher ;;
|
||||
github-desktop)
|
||||
github_latest_release desktop/desktop | sed 's/^release-//'
|
||||
;;
|
||||
happ) github_latest_release Happ-proxy/happ-desktop ;;
|
||||
netbird) github_latest_release netbirdio/netbird ;;
|
||||
nivora-stplr) current_version nivora-stplr ;;
|
||||
opencode) github_latest_release anomalyco/opencode ;;
|
||||
parsec) latest_parsec ;;
|
||||
pineconemc) github_latest_release ElyPrismLauncher/Launcher ;;
|
||||
tailscale) latest_tailscale ;;
|
||||
ventoy) github_latest_release ventoy/Ventoy ;;
|
||||
vual) github_latest_release Cheviiot/Vual ;;
|
||||
yandex-browser-stable) latest_yandex_browser ;;
|
||||
*) die "unknown package: $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_package() {
|
||||
local package="$1"
|
||||
local current latest
|
||||
require_package "$package"
|
||||
current="$(current_version "$package")" || return
|
||||
latest="$(latest_version "$package")" || return
|
||||
printf '%s %s\n' "$current" "$latest"
|
||||
}
|
||||
|
||||
check_all() {
|
||||
local package current latest status versions
|
||||
local updates=0
|
||||
printf '%-24s %-24s %-24s %s\n' PACKAGE CURRENT LATEST STATUS
|
||||
for package in "${PACKAGES[@]}"; do
|
||||
versions="$(check_package "$package")" || return
|
||||
read -r current latest <<<"$versions"
|
||||
status=current
|
||||
if [[ "$current" != "$latest" ]]; then
|
||||
status=update
|
||||
updates=1
|
||||
fi
|
||||
printf '%-24s %-24s %-24s %s\n' "$package" "$current" "$latest" "$status"
|
||||
done
|
||||
[[ "$updates" -eq 0 ]] || return 10
|
||||
}
|
||||
|
||||
outdated_packages() {
|
||||
local package current latest versions
|
||||
for package in "${PACKAGES[@]}"; do
|
||||
versions="$(check_package "$package")" || return
|
||||
read -r current latest <<<"$versions"
|
||||
if [[ "$current" != "$latest" ]]; then
|
||||
printf '%s\n' "$package"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
check)
|
||||
[[ "$#" -eq 2 ]] || die 'usage: package_updates.sh check <package>'
|
||||
check_package "$2"
|
||||
;;
|
||||
check-all)
|
||||
[[ "$#" -eq 1 ]] || die 'usage: package_updates.sh check-all'
|
||||
check_all
|
||||
;;
|
||||
outdated)
|
||||
[[ "$#" -eq 1 ]] || die 'usage: package_updates.sh outdated'
|
||||
outdated_packages
|
||||
;;
|
||||
*)
|
||||
die 'usage: package_updates.sh {check <package>|check-all|outdated}'
|
||||
;;
|
||||
esac
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
results_dir="${AUTONOMOUS_UPDATE_RESULTS_DIR:?AUTONOMOUS_UPDATE_RESULTS_DIR is required}"
|
||||
repository="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
||||
run_url="https://github.com/${repository}/actions/runs/${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}"
|
||||
issues_json="$(
|
||||
gh api --paginate --slurp \
|
||||
"repos/${repository}/issues?state=all&per_page=100"
|
||||
)"
|
||||
code_fence='```'
|
||||
|
||||
find_issue() {
|
||||
local marker="$1"
|
||||
jq -r --arg marker "$marker" '
|
||||
[
|
||||
.[][]
|
||||
| select(has("pull_request") | not)
|
||||
| select((.body // "") | contains($marker))
|
||||
][0].number // empty
|
||||
' <<<"$issues_json"
|
||||
}
|
||||
|
||||
while IFS=$'\t' read -r package phase; do
|
||||
[[ -n "$package" ]] || continue
|
||||
marker="<!-- nivora-autonomous-update:${package} -->"
|
||||
issue_number="$(find_issue "$marker")"
|
||||
report="${results_dir}/${package}/issue-body.md"
|
||||
{
|
||||
printf '%s\n\n' "$marker"
|
||||
printf 'Автономное обновление пакета **%s** остановлено на фазе **%s**.\n\n' \
|
||||
"$package" "$phase"
|
||||
printf -- '- Последний запуск: %s\n' "$run_url"
|
||||
printf -- '- Диагностика Actions хранится 30 дней в artifact запуска.\n'
|
||||
printf -- '- Остальные пакеты обновляются независимо от этого сбоя.\n\n'
|
||||
printf '### Состояние\n\n%stext\n' "$code_fence"
|
||||
sed -n '1,80p' "${results_dir}/${package}/FAILED"
|
||||
printf '%s\n\n### Последние строки журнала\n\n%stext\n' \
|
||||
"$code_fence" "$code_fence"
|
||||
tail -n 80 "${results_dir}/${package}/update.log" 2>/dev/null || true
|
||||
printf '%s\n\n### Изменения рецепта\n\n%sdiff\n' \
|
||||
"$code_fence" "$code_fence"
|
||||
sed -n '1,200p' "${results_dir}/${package}/failed.patch" \
|
||||
2>/dev/null || true
|
||||
printf '%s\n' "$code_fence"
|
||||
} >"$report"
|
||||
|
||||
if [[ -n "$issue_number" ]]; then
|
||||
gh api --method PATCH "repos/${repository}/issues/${issue_number}" \
|
||||
-f state=open \
|
||||
-f body="$(<"$report")" >/dev/null
|
||||
else
|
||||
gh api --method POST "repos/${repository}/issues" \
|
||||
-f title="[autoupdate] ${package}: требуется диагностика" \
|
||||
-f body="$(<"$report")" >/dev/null
|
||||
fi
|
||||
done <"${results_dir}/failed-packages"
|
||||
|
||||
while IFS= read -r package; do
|
||||
[[ -n "$package" ]] || continue
|
||||
marker="<!-- nivora-autonomous-update:${package} -->"
|
||||
issue_number="$(find_issue "$marker")"
|
||||
if [[ -n "$issue_number" ]]; then
|
||||
gh api --method PATCH "repos/${repository}/issues/${issue_number}" \
|
||||
-f state=closed \
|
||||
-f state_reason=completed >/dev/null
|
||||
fi
|
||||
done <"${results_dir}/successful-packages"
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
mapfile -d '' shell_files < <(
|
||||
find . -type f -not -path './.git/*' -print0 |
|
||||
while IFS= read -r -d '' file; do
|
||||
if head -n 1 -- "$file" 2>/dev/null |
|
||||
grep -IqE '^#!.*(bash|/sh)([[:space:]]|$)'; then
|
||||
printf '%s\0' "$file"
|
||||
fi
|
||||
done
|
||||
)
|
||||
|
||||
mapfile -d '' python_files < <(
|
||||
find . -type f -name '*.py' -not -path './.git/*' -print0
|
||||
)
|
||||
|
||||
for file in "${shell_files[@]}"; do
|
||||
bash -n "$file"
|
||||
done
|
||||
|
||||
if [[ "${#shell_files[@]}" -gt 0 ]]; then
|
||||
shellcheck -x "${shell_files[@]}"
|
||||
fi
|
||||
|
||||
if [[ "${#python_files[@]}" -gt 0 ]]; then
|
||||
python3 -m py_compile "${python_files[@]}"
|
||||
fi
|
||||
|
||||
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||
bash tests/test_helper.sh
|
||||
bash tests/test_claude_alt.sh
|
||||
bash tests/test_codex_computer_use.sh
|
||||
bash tests/test_happ_theme.sh
|
||||
bash tests/test_ventoy.sh
|
||||
bash tests/test_yandex_browser.sh
|
||||
python3 tools/validate_repo.py
|
||||
|
||||
if command -v stplr-spec >/dev/null 2>&1; then
|
||||
for staplerfile in */Staplerfile; do
|
||||
stplr-spec get-field --path "$staplerfile" name >/dev/null
|
||||
done
|
||||
fi
|
||||
|
||||
echo 'OK: все проверки Nivora завершены'
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
README = ROOT / "README.md"
|
||||
|
||||
|
||||
def package_metadata(package: str) -> tuple[str, list[str]]:
|
||||
text = (ROOT / package / "Staplerfile").read_text(encoding="utf-8")
|
||||
version_match = re.search(
|
||||
r"^version=(?:'([^']+)'|\"([^\"]+)\"|([^#\s]+))",
|
||||
text,
|
||||
re.MULTILINE,
|
||||
)
|
||||
if not version_match:
|
||||
raise RuntimeError(f"{package}: version is missing")
|
||||
version = next(
|
||||
value for value in version_match.groups() if value is not None
|
||||
)
|
||||
|
||||
architectures_match = re.search(
|
||||
r"^architectures=\((.*?)\)", text, re.MULTILINE | re.DOTALL
|
||||
)
|
||||
if not architectures_match:
|
||||
raise RuntimeError(f"{package}: architectures are missing")
|
||||
architectures = shlex.split(architectures_match.group(1))
|
||||
if not architectures:
|
||||
raise RuntimeError(f"{package}: architectures are empty")
|
||||
|
||||
return version, architectures
|
||||
|
||||
|
||||
def main() -> None:
|
||||
lines = README.read_text(encoding="utf-8").splitlines()
|
||||
package_dirs = sorted(
|
||||
path.parent.name for path in ROOT.glob("*/Staplerfile")
|
||||
)
|
||||
|
||||
for package in package_dirs:
|
||||
command = f"stplr install nivora/{package}"
|
||||
version, architectures = package_metadata(package)
|
||||
matching_lines = [
|
||||
index
|
||||
for index, line in enumerate(lines)
|
||||
if line.startswith("|") and command in line
|
||||
]
|
||||
if len(matching_lines) != 1:
|
||||
raise RuntimeError(
|
||||
f"{package}: expected one README catalog row, "
|
||||
f"got {len(matching_lines)}"
|
||||
)
|
||||
|
||||
index = matching_lines[0]
|
||||
cells = lines[index].split("|")
|
||||
if len(cells) != 6:
|
||||
raise RuntimeError(f"{package}: malformed README catalog row")
|
||||
cells[2] = f" `{version}` "
|
||||
cells[3] = " " + ", ".join(
|
||||
f"`{architecture}`" for architecture in architectures
|
||||
) + " "
|
||||
lines[index] = "|".join(cells)
|
||||
|
||||
README.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+448
@@ -0,0 +1,448 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||
# shellcheck source=tools/lib/source_cache.sh
|
||||
source "${script_dir}/lib/source_cache.sh"
|
||||
cd "$repo_root"
|
||||
|
||||
readonly stplr_version='0.1.1'
|
||||
readonly stplr_archive_url="https://altlinux.space/stapler/stplr/releases/download/v${stplr_version}/stplr-${stplr_version}-linux-x86_64.tar.gz"
|
||||
readonly stplr_archive_sha256='b1ec1e98c04ab928377d0cef1706e3dc62171b9e256f4bf36a219addc53117b8'
|
||||
readonly deb_image="nivora-lifecycle-deb:$$"
|
||||
readonly rpm_image="nivora-lifecycle-rpm:$$"
|
||||
readonly deb_cache_volume="nivora-lifecycle-deb-cache-$$"
|
||||
readonly deb_build_mode="${NIVORA_DEB_BUILD_MODE:-container}"
|
||||
|
||||
case "$deb_build_mode" in
|
||||
container | host) ;;
|
||||
*)
|
||||
echo "Неизвестный режим DEB-сборки: ${deb_build_mode}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# package | command | persistent state marker | desktop/unit | icon
|
||||
readonly -a lifecycle_packages=(
|
||||
'clash-verge-rev|/usr/bin/clash-verge|/home/nivora-test/.local/share/io.github.clash-verge-rev.clash-verge-rev/nivora-lifecycle-state|/usr/share/applications/Clash Verge.desktop|/usr/share/icons/hicolor/128x128/apps/clash-verge.png'
|
||||
'claude-desktop|/usr/bin/claude-desktop|/home/nivora-test/.config/Claude/nivora-lifecycle-state|/usr/share/applications/com.anthropic.Claude.desktop|/usr/share/icons/hicolor/128x128/apps/claude-desktop.png'
|
||||
'codex|/usr/bin/codex-app|/home/nivora-test/.codex/nivora-lifecycle-state|/usr/share/applications/codex-app.desktop|/usr/share/icons/hicolor/512x512/apps/codex-app.png'
|
||||
'nivora-stplr|/usr/bin/sl|/home/nivora-test/.config/nivora-stplr/nivora-lifecycle-state|-|-'
|
||||
'opencode|/usr/bin/opencode-desktop|/home/nivora-test/.config/opencode/nivora-lifecycle-state|/usr/share/applications/opencode-desktop.desktop|/usr/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png'
|
||||
'tailscale|/usr/bin/tailscale|/var/lib/tailscale/nivora-lifecycle-state|/usr/lib/systemd/system/tailscaled.service|-'
|
||||
'netbird|/usr/bin/netbird|/var/lib/netbird/nivora-lifecycle-state|/usr/lib/systemd/system/netbird.service|-'
|
||||
'chatbox|/usr/bin/chatbox|/home/nivora-test/.config/Chatbox/nivora-lifecycle-state|/usr/share/applications/xyz.chatboxapp.app.desktop|/usr/share/icons/hicolor/128x128/apps/xyz.chatboxapp.app.png'
|
||||
)
|
||||
|
||||
for command in curl docker dpkg-deb find rpm rpmbuild sha256sum sqlite3 stplr-spec tar; do
|
||||
command -v "$command" >/dev/null 2>&1 || {
|
||||
echo "Для lifecycle-теста требуется команда ${command}" >&2
|
||||
exit 2
|
||||
}
|
||||
done
|
||||
|
||||
[[ "$(uname -m)" == 'x86_64' ]] || {
|
||||
echo 'Lifecycle-тест сейчас поддерживает только x86_64 runner' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
work_dir="$(mktemp -d)"
|
||||
source_cache="${NIVORA_SOURCE_CACHE:-${XDG_CACHE_HOME:-${HOME}/.cache}/stplr/dl}"
|
||||
apparmor_userns_original=''
|
||||
|
||||
cleanup() {
|
||||
docker volume rm "$deb_cache_volume" >/dev/null 2>&1 || true
|
||||
docker image rm "$deb_image" >/dev/null 2>&1 || true
|
||||
docker image rm "$rpm_image" >/dev/null 2>&1 || true
|
||||
if [[ "$deb_build_mode" == 'host' && -d "$work_dir" ]]; then
|
||||
sudo chown -R "$(id -u):$(id -g)" "$work_dir" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "$apparmor_userns_original" ]]; then
|
||||
sudo sysctl -q -w \
|
||||
"kernel.apparmor_restrict_unprivileged_userns=${apparmor_userns_original}" \
|
||||
>/dev/null 2>&1 || true
|
||||
fi
|
||||
find "$work_dir" -mindepth 1 -delete
|
||||
rmdir "$work_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
install -d \
|
||||
"${work_dir}/builder" \
|
||||
"${work_dir}/fixtures/previous-deb" \
|
||||
"${work_dir}/fixtures/previous-rpm"
|
||||
|
||||
prepare_stplr() {
|
||||
local target="${work_dir}/builder/stplr"
|
||||
local archive actual
|
||||
|
||||
if command -v stplr >/dev/null 2>&1 &&
|
||||
[[ "$(stplr version 2>/dev/null)" == "v${stplr_version}" ]]; then
|
||||
cp "$(command -v stplr)" "$target"
|
||||
else
|
||||
archive="${work_dir}/stplr.tar.gz"
|
||||
curl -fL \
|
||||
--retry 3 \
|
||||
--retry-all-errors \
|
||||
--connect-timeout 20 \
|
||||
--max-time 300 \
|
||||
-o "$archive" \
|
||||
"$stplr_archive_url"
|
||||
actual="$(sha256sum "$archive")"
|
||||
actual="${actual%% *}"
|
||||
[[ "$actual" == "$stplr_archive_sha256" ]] || {
|
||||
echo "Неверная SHA-256 stplr: ${actual}" >&2
|
||||
exit 1
|
||||
}
|
||||
tar -xzf "$archive" -C "${work_dir}/builder" stplr
|
||||
fi
|
||||
chmod 0755 "$work_dir" "${work_dir}/builder" "$target"
|
||||
}
|
||||
|
||||
build_images() {
|
||||
cat >"${work_dir}/builder/Containerfile.deb" <<'EOF'
|
||||
FROM ubuntu:24.04
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
COPY stplr /usr/local/bin/stplr
|
||||
RUN set -eux; \
|
||||
success=0; \
|
||||
for attempt in 1 2 3; do \
|
||||
if apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=20 update \
|
||||
&& apt-get install -y ca-certificates binutils passwd python3 xz-utils zstd; then \
|
||||
success=1; \
|
||||
break; \
|
||||
fi; \
|
||||
sleep "$((attempt * 5))"; \
|
||||
done; \
|
||||
test "$success" -eq 1; \
|
||||
useradd --system --create-home stapler-builder; \
|
||||
mkdir -p /var/cache/stplr; \
|
||||
chown -R stapler-builder:stapler-builder /var/cache/stplr
|
||||
EOF
|
||||
|
||||
cat >"${work_dir}/builder/Containerfile.rpm" <<'EOF'
|
||||
FROM registry.altlinux.org/sisyphus/base:latest
|
||||
RUN for attempt in 1 2 3; do \
|
||||
apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=20 update \
|
||||
&& apt-get dist-upgrade -y \
|
||||
&& apt-get install -y ca-certificates stplr binutils python3 \
|
||||
&& exit 0; \
|
||||
sleep "$((attempt * 5))"; \
|
||||
done; \
|
||||
exit 1
|
||||
EOF
|
||||
|
||||
docker build -t "$deb_image" -f "${work_dir}/builder/Containerfile.deb" \
|
||||
"${work_dir}/builder"
|
||||
docker build -t "$rpm_image" -f "${work_dir}/builder/Containerfile.rpm" \
|
||||
"${work_dir}/builder"
|
||||
}
|
||||
|
||||
prepare_host_deb_builder() {
|
||||
if ! getent group wheel >/dev/null; then
|
||||
sudo groupadd --system wheel
|
||||
fi
|
||||
if ! getent passwd stapler-builder >/dev/null; then
|
||||
sudo useradd --system --create-home stapler-builder
|
||||
fi
|
||||
sudo usermod -a -G wheel stapler-builder
|
||||
sudo install -d -o stapler-builder -g stapler-builder /var/cache/stplr
|
||||
sudo -u stapler-builder test -x "${work_dir}/builder/stplr"
|
||||
|
||||
if [[ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then
|
||||
apparmor_userns_original="$(
|
||||
sysctl -n kernel.apparmor_restrict_unprivileged_userns
|
||||
)"
|
||||
if [[ "$apparmor_userns_original" != '0' ]]; then
|
||||
sudo sysctl -q -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
else
|
||||
apparmor_userns_original=''
|
||||
fi
|
||||
fi
|
||||
|
||||
sudo -u stapler-builder unshare \
|
||||
--user --map-root-user --mount --pid --fork --uts --ipc --cgroup \
|
||||
true
|
||||
}
|
||||
|
||||
build_deb_on_host() {
|
||||
local package="$1"
|
||||
local stage="${work_dir}/host-packages/${package}"
|
||||
local builder_home
|
||||
local -a artifacts
|
||||
|
||||
install -d "$stage"
|
||||
cp -a "${package}/." "$stage/"
|
||||
find "$stage" -maxdepth 1 -type f \( -name '*.deb' -o -name '*.rpm' \) -delete
|
||||
sudo chown -R stapler-builder:stapler-builder "$stage"
|
||||
builder_home="$(getent passwd stapler-builder | cut -d: -f6)"
|
||||
|
||||
(
|
||||
cd "$stage"
|
||||
sudo -u stapler-builder env \
|
||||
HOME="$builder_home" \
|
||||
TERM=xterm-256color \
|
||||
"${work_dir}/builder/stplr" \
|
||||
--interactive=false build --clean -s Staplerfile
|
||||
)
|
||||
|
||||
mapfile -t artifacts < <(find "$stage" -maxdepth 1 -type f -name '*.deb' -print)
|
||||
[[ "${#artifacts[@]}" -eq 1 ]]
|
||||
install -m0644 "${artifacts[0]}" "${package}/$(basename "${artifacts[0]}")"
|
||||
}
|
||||
|
||||
mapfile -t packages < <(
|
||||
printf '%s\n' "${lifecycle_packages[@]}" | cut -d '|' -f 1 | sort -u
|
||||
)
|
||||
|
||||
missing_rpm=()
|
||||
for package in "${packages[@]}"; do
|
||||
mapfile -t artifacts < <(find "$package" -maxdepth 1 -type f -name '*.rpm' -print)
|
||||
if [[ "${#artifacts[@]}" -ne 1 ]]; then
|
||||
missing_rpm+=("$package")
|
||||
fi
|
||||
done
|
||||
if [[ "${#missing_rpm[@]}" -gt 0 ]]; then
|
||||
"${script_dir}/clean_build.sh" "${missing_rpm[@]}"
|
||||
fi
|
||||
"${script_dir}/verify_artifacts.sh" "${packages[@]}"
|
||||
|
||||
prepare_stplr
|
||||
build_images
|
||||
if [[ "$deb_build_mode" == 'container' ]]; then
|
||||
docker volume create "$deb_cache_volume" >/dev/null
|
||||
import_stplr_source_cache \
|
||||
docker "$deb_image" "$deb_cache_volume" "$work_dir" "$source_cache" \
|
||||
"${packages[@]}"
|
||||
else
|
||||
echo '==> DEB packages build directly on the host runner'
|
||||
prepare_host_deb_builder
|
||||
fi
|
||||
|
||||
deb_is_current() {
|
||||
local package="$1"
|
||||
local expected_name expected_version expected_release artifact
|
||||
local -a existing
|
||||
|
||||
mapfile -t existing < <(find "$package" -maxdepth 1 -type f -name '*.deb' -print)
|
||||
[[ "${#existing[@]}" -eq 1 ]] || return 1
|
||||
artifact="${existing[0]}"
|
||||
expected_name="$(stplr-spec get-field --path "${package}/Staplerfile" name)"
|
||||
expected_version="$(stplr-spec get-field --path "${package}/Staplerfile" version)"
|
||||
expected_release="$(stplr-spec get-field --path "${package}/Staplerfile" release)"
|
||||
|
||||
[[ "$(dpkg-deb -f "$artifact" Package 2>/dev/null)" == "${expected_name}+stplr-default" ]] &&
|
||||
[[ "$(dpkg-deb -f "$artifact" Version 2>/dev/null)" == "${expected_version}-${expected_release}" ]]
|
||||
}
|
||||
|
||||
for package in "${packages[@]}"; do
|
||||
if [[ "${NIVORA_REBUILD_DEB:-0}" != '1' ]] && deb_is_current "$package"; then
|
||||
echo "==> DEB reuse ${package}"
|
||||
continue
|
||||
fi
|
||||
echo "==> DEB build ${package}"
|
||||
find "$package" -maxdepth 1 -type f -name '*.deb' -delete
|
||||
if [[ "$deb_build_mode" == 'host' ]]; then
|
||||
build_deb_on_host "$package"
|
||||
else
|
||||
docker run --rm --privileged \
|
||||
-e TERM=xterm-256color \
|
||||
-v "${repo_root}/${package}:/app" \
|
||||
-v "${deb_cache_volume}:/var/cache/stplr" \
|
||||
-w /app \
|
||||
"$deb_image" \
|
||||
stplr --interactive=false build --clean -s Staplerfile
|
||||
fi
|
||||
done
|
||||
|
||||
build_previous_deb() {
|
||||
local package="$1"
|
||||
local system_name="$2"
|
||||
local root="${work_dir}/previous-deb/${package}"
|
||||
|
||||
install -d \
|
||||
"${root}/DEBIAN" \
|
||||
"${root}/usr/share/nivora-lifecycle-previous"
|
||||
printf '%s\n' \
|
||||
"Package: ${system_name}" \
|
||||
'Version: 0:0.0.2-1' \
|
||||
'Architecture: all' \
|
||||
'Maintainer: Nivora tests <noreply@example.invalid>' \
|
||||
"Provides: ${package}" \
|
||||
"Replaces: ${package}" \
|
||||
"Conflicts: ${package}" \
|
||||
'Description: Previous Nivora package fixture for lifecycle tests' \
|
||||
>"${root}/DEBIAN/control"
|
||||
printf 'previous fixture\n' \
|
||||
>"${root}/usr/share/nivora-lifecycle-previous/${package}"
|
||||
dpkg-deb --root-owner-group --build "$root" \
|
||||
"${work_dir}/fixtures/previous-deb/${package}.deb" >/dev/null
|
||||
}
|
||||
|
||||
build_previous_rpm() {
|
||||
local package="$1"
|
||||
local system_name="$2"
|
||||
local topdir="${work_dir}/previous-rpmbuild/${package}"
|
||||
local spec="${topdir}/SPECS/previous.spec"
|
||||
local built
|
||||
|
||||
install -d \
|
||||
"${topdir}/BUILD" \
|
||||
"${topdir}/BUILDROOT" \
|
||||
"${topdir}/RPMS" \
|
||||
"${topdir}/SOURCES" \
|
||||
"${topdir}/SPECS" \
|
||||
"${topdir}/SRPMS" \
|
||||
"${topdir}/TMP"
|
||||
printf '%s\n' \
|
||||
"Name: ${system_name}" \
|
||||
'Version: 0.0.2' \
|
||||
'Release: 1' \
|
||||
'Summary: Previous Nivora package fixture for lifecycle tests' \
|
||||
'Group: System/Configuration/Packaging' \
|
||||
'License: MIT' \
|
||||
'BuildArch: noarch' \
|
||||
"Provides: ${package}" \
|
||||
"Obsoletes: ${package}" \
|
||||
"Conflicts: ${package}" \
|
||||
'%description' \
|
||||
'Previous Nivora package fixture for lifecycle tests.' \
|
||||
'%install' \
|
||||
'mkdir -p %{buildroot}/usr/share/nivora-lifecycle-previous' \
|
||||
"printf 'previous fixture\\n' >%{buildroot}/usr/share/nivora-lifecycle-previous/${package}" \
|
||||
'%files' \
|
||||
"/usr/share/nivora-lifecycle-previous/${package}" \
|
||||
>"$spec"
|
||||
if ! rpmbuild \
|
||||
--define "_topdir ${topdir}" \
|
||||
--define "_tmppath ${topdir}/TMP" \
|
||||
-bb "$spec" >"${topdir}/build.log" 2>&1; then
|
||||
cat "${topdir}/build.log" >&2
|
||||
return 1
|
||||
fi
|
||||
built="$(find "${topdir}/RPMS" -type f -name '*.rpm' -print -quit)"
|
||||
[[ -n "$built" ]]
|
||||
cp "$built" "${work_dir}/fixtures/previous-rpm/${package}.rpm"
|
||||
}
|
||||
|
||||
metadata_contains() {
|
||||
local value="$1"
|
||||
local expected="$2"
|
||||
value="${value//|/,}"
|
||||
value="${value// /}"
|
||||
[[ ",${value}," == *",${expected},"* ]]
|
||||
}
|
||||
|
||||
printf '%s\n' "${lifecycle_packages[@]}" >"${work_dir}/lifecycle-packages.txt"
|
||||
|
||||
while IFS='|' read -r package _; do
|
||||
mapfile -t debs < <(find "$package" -maxdepth 1 -type f -name '*.deb' -print)
|
||||
mapfile -t rpms < <(find "$package" -maxdepth 1 -type f -name '*.rpm' -print)
|
||||
[[ "${#debs[@]}" -eq 1 && "${#rpms[@]}" -eq 1 ]] || {
|
||||
echo "${package}: ожидалось по одному DEB и RPM" >&2
|
||||
exit 1
|
||||
}
|
||||
build_previous_deb "$package" "$(dpkg-deb -f "${debs[0]}" Package)"
|
||||
build_previous_rpm "$package" "$(rpm -qp --queryformat '%{NAME}' "${rpms[0]}")"
|
||||
|
||||
for field in Provides Replaces Conflicts; do
|
||||
value="$(dpkg-deb -f "${debs[0]}" "$field")"
|
||||
metadata_contains "$value" "$package" || {
|
||||
echo "${package}: DEB ${field} не содержит ${package}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
rpm -qp --provides "${rpms[0]}" | grep -Fxq "$package"
|
||||
rpm -qp --obsoletes "${rpms[0]}" | grep -Fxq "$package"
|
||||
rpm -qp --conflicts "${rpms[0]}" | grep -Fxq "$package"
|
||||
done <"${work_dir}/lifecycle-packages.txt"
|
||||
|
||||
cat >"${work_dir}/run-deb.sh" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
useradd --create-home nivora-test
|
||||
|
||||
is_installed() {
|
||||
[[ "$(dpkg-query -W -f='${db:Status-Abbrev}' "$1" 2>/dev/null || true)" == 'ii '* ]]
|
||||
}
|
||||
|
||||
while IFS='|' read -r package command_path state_path expected_a expected_b; do
|
||||
mapfile -t artifacts < <(find "/repo/${package}" -maxdepth 1 -type f -name '*.deb' -print)
|
||||
[[ "${#artifacts[@]}" -eq 1 ]]
|
||||
artifact="${artifacts[0]}"
|
||||
system_name="$(dpkg-deb -f "$artifact" Package)"
|
||||
|
||||
echo "==> DEB lifecycle ${package}"
|
||||
dpkg -i "/previous/${package}.deb"
|
||||
install -d "${state_path%/*}"
|
||||
printf 'keep\n' >"$state_path"
|
||||
if [[ "$state_path" == /home/nivora-test/* ]]; then
|
||||
chown -R nivora-test:nivora-test /home/nivora-test
|
||||
fi
|
||||
|
||||
apt-get -qq install -y "$artifact"
|
||||
is_installed "$system_name"
|
||||
test -x "$command_path"
|
||||
[[ "$expected_a" == '-' ]] || test -e "$expected_a"
|
||||
[[ "$expected_b" == '-' ]] || test -e "$expected_b"
|
||||
|
||||
apt-get -qq remove -y "$system_name"
|
||||
test -f "$state_path"
|
||||
! is_installed "$system_name"
|
||||
done </lifecycle-packages.txt
|
||||
EOF
|
||||
|
||||
cat >"${work_dir}/run-rpm.sh" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
useradd --create-home nivora-test
|
||||
|
||||
while IFS='|' read -r package command_path state_path expected_a expected_b; do
|
||||
mapfile -t artifacts < <(find "/repo/${package}" -maxdepth 1 -type f -name '*.rpm' -print)
|
||||
[[ "${#artifacts[@]}" -eq 1 ]]
|
||||
artifact="${artifacts[0]}"
|
||||
system_name="$(rpm -qp --queryformat '%{NAME}' "$artifact")"
|
||||
|
||||
echo "==> RPM lifecycle ${package}"
|
||||
rpm -ivh --quiet "/previous/${package}.rpm"
|
||||
install -d "${state_path%/*}"
|
||||
printf 'keep\n' >"$state_path"
|
||||
if [[ "$state_path" == /home/nivora-test/* ]]; then
|
||||
chown -R nivora-test:nivora-test /home/nivora-test
|
||||
fi
|
||||
|
||||
apt-get -qq install -y "$artifact"
|
||||
rpm -q "$system_name"
|
||||
test -x "$command_path"
|
||||
[[ "$expected_a" == '-' ]] || test -e "$expected_a"
|
||||
[[ "$expected_b" == '-' ]] || test -e "$expected_b"
|
||||
|
||||
apt-get -qq remove -y "$system_name"
|
||||
test -f "$state_path"
|
||||
! rpm -q "$system_name"
|
||||
done </lifecycle-packages.txt
|
||||
EOF
|
||||
|
||||
chmod 0755 "${work_dir}/run-deb.sh" "${work_dir}/run-rpm.sh"
|
||||
|
||||
docker run --rm --privileged \
|
||||
-v "${repo_root}:/repo:ro" \
|
||||
-v "${work_dir}/fixtures/previous-deb:/previous:ro" \
|
||||
-v "${work_dir}/lifecycle-packages.txt:/lifecycle-packages.txt:ro" \
|
||||
-v "${work_dir}/run-deb.sh:/run-lifecycle.sh:ro" \
|
||||
"$deb_image" \
|
||||
/run-lifecycle.sh
|
||||
|
||||
docker run --rm --privileged \
|
||||
-v "${repo_root}:/repo:ro" \
|
||||
-v "${work_dir}/fixtures/previous-rpm:/previous:ro" \
|
||||
-v "${work_dir}/lifecycle-packages.txt:/lifecycle-packages.txt:ro" \
|
||||
-v "${work_dir}/run-rpm.sh:/run-lifecycle.sh:ro" \
|
||||
"$rpm_image" \
|
||||
/run-lifecycle.sh
|
||||
|
||||
echo "OK: DEB/RPM lifecycle проверен для ${#lifecycle_packages[@]} пакетов"
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
EXPECTED_PACKAGES = (
|
||||
"adwyra",
|
||||
"anidesk",
|
||||
"balena-etcher",
|
||||
"chatbox",
|
||||
"clash-verge-rev",
|
||||
"claude-desktop",
|
||||
"codex",
|
||||
"fisher",
|
||||
"github-desktop",
|
||||
"happ",
|
||||
"netbird",
|
||||
"nivora-stplr",
|
||||
"opencode",
|
||||
"parsec",
|
||||
"pineconemc",
|
||||
"tailscale",
|
||||
"ventoy",
|
||||
"vual",
|
||||
"yandex-browser-stable",
|
||||
)
|
||||
|
||||
REQUIRED_ROOT_FILES = {
|
||||
Path("README.md"),
|
||||
Path("CHANGELOG.md"),
|
||||
Path("CONTRIBUTING.md"),
|
||||
Path("SECURITY.md"),
|
||||
Path("LICENSE"),
|
||||
Path("stapler-repo.toml"),
|
||||
Path("docs/maintenance.md"),
|
||||
Path("docs/security-model.md"),
|
||||
Path("docs/packages/claude-desktop.md"),
|
||||
Path("docs/packages/codex.md"),
|
||||
Path("docs/packages/github-desktop.md"),
|
||||
Path("docs/packages/opencode.md"),
|
||||
Path("docs/packages/nivora-stplr.md"),
|
||||
Path("docs/packages/ventoy.md"),
|
||||
}
|
||||
|
||||
CHECKSUM_RE = re.compile(r"(?:sha256:)?[0-9a-f]{64}\Z")
|
||||
MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)\s]+)(?:\s+[^)]*)?\)")
|
||||
HTML_LINK_RE = re.compile(r"(?:src|href)=[\"']([^\"']+)[\"']")
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(r"-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----"),
|
||||
re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"),
|
||||
re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
|
||||
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
||||
)
|
||||
|
||||
|
||||
def scalar(text: str, field: str) -> str | None:
|
||||
match = re.search(
|
||||
rf"^{re.escape(field)}=(?:'([^']*)'|\"([^\"]*)\"|([^#\n]+))",
|
||||
text,
|
||||
re.MULTILINE,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
return next(value.strip() for value in match.groups() if value is not None)
|
||||
|
||||
|
||||
def array(text: str, field: str) -> list[str] | None:
|
||||
match = re.search(
|
||||
rf"^{re.escape(field)}=\((.*?)\)", text, re.MULTILINE | re.DOTALL
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return shlex.split(match.group(1), comments=True, posix=True)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def source_arrays(text: str) -> dict[str, list[str]]:
|
||||
result: dict[str, list[str]] = {}
|
||||
for match in re.finditer(r"^(sources(?:_[a-z0-9_]+)?)=\(", text, re.MULTILINE):
|
||||
name = match.group(1)
|
||||
values = array(text, name)
|
||||
if values is not None:
|
||||
result[name] = values
|
||||
return result
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def local_source_name(source: str) -> str | None:
|
||||
if not source.startswith("local:///"):
|
||||
return None
|
||||
value = unquote(source.removeprefix("local:///").split("?", 1)[0])
|
||||
path = Path(value)
|
||||
if not value or path.is_absolute() or ".." in path.parts:
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def markdown_targets(text: str) -> set[str]:
|
||||
return set(MARKDOWN_LINK_RE.findall(text)) | set(HTML_LINK_RE.findall(text))
|
||||
|
||||
|
||||
def validate_links(path: Path, errors: list[str]) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for target in markdown_targets(text):
|
||||
parsed = urlsplit(target)
|
||||
if parsed.scheme or target.startswith(("mailto:", "#")):
|
||||
continue
|
||||
local = unquote(parsed.path)
|
||||
if not local:
|
||||
continue
|
||||
if local.startswith("/"):
|
||||
errors.append(f"{path.relative_to(ROOT)}: unsafe local link: {target}")
|
||||
continue
|
||||
resolved = (path.parent / local).resolve()
|
||||
try:
|
||||
resolved.relative_to(ROOT)
|
||||
except ValueError:
|
||||
errors.append(f"{path.relative_to(ROOT)}: link escapes repository: {target}")
|
||||
continue
|
||||
if not resolved.exists():
|
||||
errors.append(f"{path.relative_to(ROOT)}: missing link target: {target}")
|
||||
|
||||
|
||||
def validate_package(package: str, errors: list[str]) -> dict[str, object]:
|
||||
directory = ROOT / package
|
||||
staplerfile = directory / "Staplerfile"
|
||||
text = staplerfile.read_text(encoding="utf-8")
|
||||
|
||||
name = scalar(text, "name")
|
||||
version = scalar(text, "version")
|
||||
release = scalar(text, "release")
|
||||
architectures = array(text, "architectures")
|
||||
provides = array(text, "provides")
|
||||
replaces = array(text, "replaces")
|
||||
conflicts = array(text, "conflicts")
|
||||
|
||||
if name != package:
|
||||
errors.append(f"{package}: directory and name differ: {name!r}")
|
||||
if not version:
|
||||
errors.append(f"{package}: version is missing")
|
||||
if not release or not release.isdigit() or int(release) < 1:
|
||||
errors.append(f"{package}: release must be a positive integer")
|
||||
if not architectures:
|
||||
errors.append(f"{package}: architectures are missing")
|
||||
elif any(item not in {"amd64", "arm64", "all"} for item in architectures):
|
||||
errors.append(f"{package}: unsupported architecture value: {architectures}")
|
||||
|
||||
if provides != [] or conflicts != []:
|
||||
errors.append(f"{package}: provides/conflicts must not contain binary aliases")
|
||||
if replaces != [package]:
|
||||
errors.append(f"{package}: replaces must contain only its own base package name")
|
||||
|
||||
if "package()" not in text or "files()" not in text:
|
||||
errors.append(f"{package}: package() or files() is missing")
|
||||
|
||||
arrays = source_arrays(text)
|
||||
if not arrays:
|
||||
errors.append(f"{package}: sources are missing")
|
||||
for source_field, sources in arrays.items():
|
||||
checksum_field = source_field.replace("sources", "checksums", 1)
|
||||
checksums = array(text, checksum_field)
|
||||
if checksums is None:
|
||||
errors.append(f"{package}: {checksum_field} is missing")
|
||||
continue
|
||||
if len(sources) != len(checksums):
|
||||
errors.append(
|
||||
f"{package}: {source_field}/{checksum_field} lengths differ "
|
||||
f"({len(sources)} != {len(checksums)})"
|
||||
)
|
||||
continue
|
||||
for source, checksum in zip(sources, checksums, strict=True):
|
||||
if checksum == "SKIP" or not CHECKSUM_RE.fullmatch(checksum):
|
||||
errors.append(f"{package}: invalid checksum for {source}: {checksum}")
|
||||
continue
|
||||
if source.startswith("http://"):
|
||||
errors.append(f"{package}: insecure source URL: {source}")
|
||||
if source.startswith("git+") and "#" not in source:
|
||||
errors.append(f"{package}: unpinned Git source: {source}")
|
||||
|
||||
local_name = local_source_name(source)
|
||||
if local_name is None:
|
||||
continue
|
||||
if local_name == "":
|
||||
errors.append(f"{package}: unsafe local source: {source}")
|
||||
continue
|
||||
local_path = directory / local_name
|
||||
if not local_path.is_file():
|
||||
errors.append(f"{package}: missing local source: {local_name}")
|
||||
continue
|
||||
expected = checksum.removeprefix("sha256:")
|
||||
actual = sha256(local_path)
|
||||
if actual != expected:
|
||||
errors.append(
|
||||
f"{package}: checksum mismatch for {local_name}: {actual} != {expected}"
|
||||
)
|
||||
|
||||
for hook in re.findall(r"\['[^']+'\]='([^']+)'", text):
|
||||
hook_path = directory / hook
|
||||
if not hook_path.is_file():
|
||||
errors.append(f"{package}: missing lifecycle script: {hook}")
|
||||
elif not os.access(hook_path, os.X_OK):
|
||||
errors.append(f"{package}: lifecycle script is not executable: {hook}")
|
||||
|
||||
update_check = directory / ".stapler/update-check"
|
||||
if not update_check.is_file() or not os.access(update_check, os.X_OK):
|
||||
errors.append(f"{package}: executable .stapler/update-check is required")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"version": version,
|
||||
"architectures": architectures or [],
|
||||
}
|
||||
|
||||
|
||||
def validate_readme(metadata: dict[str, dict[str, object]], errors: list[str]) -> None:
|
||||
path = ROOT / "README.md"
|
||||
text = path.read_text(encoding="utf-8")
|
||||
count_match = re.search(r"<!--\s*package-count\s*-->\s*\*\*(\d+) пакет", text)
|
||||
if not count_match or int(count_match.group(1)) != len(EXPECTED_PACKAGES):
|
||||
errors.append("README.md: package counter is stale")
|
||||
if text.count("### ") != 6:
|
||||
errors.append("README.md: catalog must contain exactly six categories")
|
||||
|
||||
for package, values in metadata.items():
|
||||
command = f"stplr install nivora/{package}"
|
||||
catalog_rows = [
|
||||
line for line in text.splitlines() if line.startswith("|") and command in line
|
||||
]
|
||||
if len(catalog_rows) != 1:
|
||||
errors.append(f"README.md: expected one catalog command for {package}")
|
||||
version = str(values["version"])
|
||||
if f"`{version}`" not in text:
|
||||
errors.append(f"README.md: version {version} is missing for {package}")
|
||||
|
||||
|
||||
def validate_repository_text(errors: list[str]) -> None:
|
||||
for path in ROOT.rglob("*"):
|
||||
if not path.is_file() or ".git" in path.parts:
|
||||
continue
|
||||
relative = path.relative_to(ROOT)
|
||||
if path.suffix.lower() in {".png", ".ico", ".zip", ".gz"}:
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
scans_validator_source = relative == Path("tools/validate_repo.py")
|
||||
if not scans_validator_source and (
|
||||
"/home/cheviiot" in text or "/.codex/attachments/" in text
|
||||
):
|
||||
errors.append(f"{relative}: personal path is forbidden")
|
||||
if not scans_validator_source and "chmod 777" in text:
|
||||
errors.append(f"{relative}: chmod 777 is forbidden")
|
||||
if not scans_validator_source and re.search(r"\brm\s+-rf\b", text):
|
||||
allowed_purge = relative in {
|
||||
Path("tailscale/tailscale-purge-data"),
|
||||
Path("netbird/netbird-purge-data"),
|
||||
}
|
||||
if not allowed_purge or "--yes" not in text:
|
||||
errors.append(f"{relative}: unsafe rm -rf")
|
||||
for pattern in SECRET_PATTERNS:
|
||||
if pattern.search(text):
|
||||
errors.append(f"{relative}: possible secret detected")
|
||||
|
||||
if text.startswith("#!") and not os.access(path, os.X_OK):
|
||||
errors.append(f"{relative}: script is not executable")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
|
||||
for required in sorted(REQUIRED_ROOT_FILES):
|
||||
if not (ROOT / required).is_file():
|
||||
errors.append(f"missing required file: {required}")
|
||||
|
||||
package_dirs = tuple(
|
||||
sorted(path.name for path in ROOT.iterdir() if (path / "Staplerfile").is_file())
|
||||
)
|
||||
if package_dirs != EXPECTED_PACKAGES:
|
||||
errors.append(
|
||||
"package list mismatch: "
|
||||
f"expected {', '.join(EXPECTED_PACKAGES)}; got {', '.join(package_dirs)}"
|
||||
)
|
||||
|
||||
metadata: dict[str, dict[str, object]] = {}
|
||||
for package in package_dirs:
|
||||
metadata[package] = validate_package(package, errors)
|
||||
|
||||
validate_readme(metadata, errors)
|
||||
for path in sorted([*ROOT.glob("*.md"), *ROOT.glob("docs/**/*.md")]):
|
||||
validate_links(path, errors)
|
||||
validate_repository_text(errors)
|
||||
|
||||
if errors:
|
||||
print("Nivora validation failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"OK: validated {len(package_dirs)} Nivora packages")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+330
@@ -0,0 +1,330 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
mapfile -t all_packages < <(
|
||||
for staplerfile in */Staplerfile; do
|
||||
dirname "$staplerfile"
|
||||
done | sort
|
||||
)
|
||||
|
||||
packages=()
|
||||
if [[ "${1:-}" == '--all' ]]; then
|
||||
packages=("${all_packages[@]}")
|
||||
shift
|
||||
else
|
||||
packages=("$@")
|
||||
fi
|
||||
|
||||
[[ "${#packages[@]}" -gt 0 ]] || {
|
||||
echo 'Использование: tools/verify_artifacts.sh {--all|package...}' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
command -v rpm >/dev/null 2>&1 || {
|
||||
echo 'Для проверки RPM требуется команда rpm' >&2
|
||||
exit 2
|
||||
}
|
||||
command -v stplr-spec >/dev/null 2>&1 || {
|
||||
echo 'Для проверки метаданных требуется stplr-spec' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
contains_path() {
|
||||
local pattern="$1"
|
||||
local path
|
||||
for path in "${payload[@]}"; do
|
||||
# shellcheck disable=SC2053
|
||||
[[ "$path" == $pattern ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
for package in "${packages[@]}"; do
|
||||
[[ -f "${package}/Staplerfile" ]] || {
|
||||
echo "Неизвестный пакет: ${package}" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
mapfile -t artifacts < <(find "$package" -maxdepth 1 -type f -name '*.rpm' -print)
|
||||
[[ "${#artifacts[@]}" -eq 1 ]] || {
|
||||
echo "${package}: ожидался один RPM, найдено ${#artifacts[@]}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
artifact="${artifacts[0]}"
|
||||
package_name="$(stplr-spec get-field --path "${package}/Staplerfile" name)"
|
||||
rpm_name="$(rpm -qp --queryformat '%{NAME}' "$artifact")"
|
||||
[[ "$rpm_name" == "${package_name}+stplr-"* ]] || {
|
||||
echo "${package}: неверное имя RPM: ${rpm_name}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mapfile -t payload < <(rpm -qlp "$artifact")
|
||||
contains_path "/usr/share/licenses/${package_name}/*" || {
|
||||
echo "${package}: отсутствует лицензия в собственном namespace" >&2
|
||||
exit 1
|
||||
}
|
||||
contains_path '/usr/share/licenses/LICENSE' && {
|
||||
echo "${package}: обнаружен общий конфликтный путь лицензии" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
recipe="$(<"${package}/Staplerfile")"
|
||||
if [[ "$recipe" == *'files-find-binary'* ]]; then
|
||||
contains_path '/usr/bin/*' || contains_path '/usr/sbin/*' || {
|
||||
echo "${package}: files-find-binary не добавил исполняемые файлы" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
if [[ "$recipe" == *'files-find-desktop'* ]]; then
|
||||
contains_path '/usr/share/applications/*.desktop' || {
|
||||
echo "${package}: files-find-desktop не добавил desktop-файл" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
if [[ "$recipe" == *'files-find-systemd'* ]]; then
|
||||
contains_path '/usr/lib/systemd/system/*' || {
|
||||
echo "${package}: files-find-systemd не добавил unit-файл" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
if [[ "$package" == 'claude-desktop' ]]; then
|
||||
for required_path in \
|
||||
/usr/bin/claude-alt \
|
||||
/usr/bin/claude-desktop-account2 \
|
||||
/usr/lib/claude-desktop/resources/TrayIconLinux.png \
|
||||
/usr/lib/claude-desktop/resources/TrayIconLinux-Dark.png \
|
||||
/usr/lib/claude-alt/claude-alt-bin \
|
||||
/usr/lib/claude-alt/resources/app.asar \
|
||||
/usr/lib/claude-alt/resources/icon.png \
|
||||
/usr/lib/claude-alt/resources/TrayIconLinux.png \
|
||||
/usr/lib/claude-alt/resources/TrayIconLinux-Dark.png \
|
||||
/usr/share/applications/com.anthropic.ClaudeAlt.desktop \
|
||||
/usr/share/icons/hicolor/512x512/apps/claude-alt.png; do
|
||||
contains_path "$required_path" || {
|
||||
echo "${package}: отсутствует компонент ClaudeAlt: ${required_path}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
contains_path '/usr/share/applications/claude-desktop-account2.desktop' && {
|
||||
echo "${package}: обнаружен устаревший desktop-файл второго профиля" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
if [[ "$package" == 'balena-etcher' ]]; then
|
||||
for required_path in \
|
||||
/usr/bin/balena-etcher \
|
||||
/usr/lib/balena-etcher/balena-etcher \
|
||||
/usr/lib/balena-etcher/balenaEtcher \
|
||||
/usr/lib/balena-etcher/resources/etcher-util \
|
||||
/usr/share/applications/balena-etcher.desktop \
|
||||
/usr/share/pixmaps/balena-etcher.png; do
|
||||
contains_path "$required_path" || {
|
||||
echo "${package}: отсутствует компонент balenaEtcher: ${required_path}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
balena_command_target="$(
|
||||
rpm -qp --dump "$artifact" |
|
||||
awk '$1 == "/usr/bin/balena-etcher" {print $11}'
|
||||
)"
|
||||
balena_alias_target="$(
|
||||
rpm -qp --dump "$artifact" |
|
||||
awk '$1 == "/usr/lib/balena-etcher/balenaEtcher" {print $11}'
|
||||
)"
|
||||
[[ "$balena_command_target" == '../lib/balena-etcher/balena-etcher' ]] || {
|
||||
echo "${package}: неверная ссылка /usr/bin/balena-etcher" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$balena_alias_target" == 'balena-etcher' ]] || {
|
||||
echo "${package}: обнаружена битая upstream-ссылка balenaEtcher" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
if [[ "$package" == 'codex' ]]; then
|
||||
for required_path in \
|
||||
/usr/bin/codex-app \
|
||||
/usr/bin/codex-computer-use-linux \
|
||||
/opt/codex-app/resources/app.asar \
|
||||
/opt/codex-app/resources/codex.asar \
|
||||
/opt/codex-app/resources/plugins/openai-bundled/.agents/plugins/marketplace.json \
|
||||
/opt/codex-app/resources/plugins/openai-bundled/plugins/computer-use/.codex-plugin/plugin.json \
|
||||
/opt/codex-app/resources/plugins/openai-bundled/plugins/computer-use/.mcp.json \
|
||||
/opt/codex-app/resources/plugins/openai-bundled/plugins/computer-use/assets/app-icon.png \
|
||||
/opt/codex-app/resources/plugins/openai-bundled/plugins/computer-use/bin/codex-computer-use-linux \
|
||||
/opt/codex-app/resources/plugins/openai-bundled/plugins/computer-use/bin/codex-computer-use-cosmic \
|
||||
/opt/codex-app/resources/plugins/openai-bundled/plugins/computer-use/bin/computer-use-linux-cosmic \
|
||||
/usr/share/applications/codex-app.desktop \
|
||||
/usr/share/icons/hicolor/512x512/apps/codex-app.png; do
|
||||
contains_path "$required_path" || {
|
||||
echo "${package}: отсутствует upstream-компонент Codex: ${required_path}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
codex_command_target="$(
|
||||
rpm -qp --dump "$artifact" |
|
||||
awk '$1 == "/usr/bin/codex-app" {print $11}'
|
||||
)"
|
||||
[[ "$codex_command_target" == '/opt/codex-app/codex-app' ]] || {
|
||||
echo "${package}: команда Codex запускается не напрямую: ${codex_command_target}" >&2
|
||||
exit 1
|
||||
}
|
||||
codex_computer_use_target="$(
|
||||
rpm -qp --dump "$artifact" |
|
||||
awk '$1 == "/usr/bin/codex-computer-use-linux" {print $11}'
|
||||
)"
|
||||
[[ "$codex_computer_use_target" == '/opt/codex-app/resources/plugins/openai-bundled/plugins/computer-use/bin/codex-computer-use-linux' ]] || {
|
||||
echo "${package}: неверная ссылка Computer Use: ${codex_computer_use_target}" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
if [[ "$package" == 'github-desktop' ]]; then
|
||||
for required_path in \
|
||||
/usr/bin/github-desktop \
|
||||
/opt/github-desktop/desktop \
|
||||
/opt/github-desktop/resources/app \
|
||||
/usr/share/applications/github-desktop.desktop \
|
||||
/usr/share/icons/hicolor/scalable/apps/github-desktop.svg; do
|
||||
contains_path "$required_path" || {
|
||||
echo "${package}: отсутствует компонент GitHub Desktop: ${required_path}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
github_desktop_target="$(
|
||||
rpm -qp --dump "$artifact" |
|
||||
awk '$1 == "/usr/bin/github-desktop" {print $11}'
|
||||
)"
|
||||
[[ "$github_desktop_target" == '/opt/github-desktop/desktop' ]] || {
|
||||
echo "${package}: команда GitHub Desktop запускается не напрямую" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
if [[ "$package" == 'opencode' ]]; then
|
||||
for required_path in \
|
||||
/usr/bin/opencode-desktop \
|
||||
/opt/OpenCode/ai.opencode.desktop \
|
||||
/opt/OpenCode/resources/app.asar \
|
||||
/usr/share/applications/opencode-desktop.desktop \
|
||||
/usr/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png; do
|
||||
contains_path "$required_path" || {
|
||||
echo "${package}: отсутствует upstream-компонент OpenCode: ${required_path}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
opencode_command_target="$(
|
||||
rpm -qp --dump "$artifact" |
|
||||
awk '$1 == "/usr/bin/opencode-desktop" {print $11}'
|
||||
)"
|
||||
[[ "$opencode_command_target" == '/opt/OpenCode/ai.opencode.desktop' ]] || {
|
||||
echo "${package}: команда OpenCode запускается не напрямую: ${opencode_command_target}" >&2
|
||||
exit 1
|
||||
}
|
||||
contains_path '/usr/lib/opencode-desktop/*' && {
|
||||
echo "${package}: обнаружен удалённый wrapper OpenCode" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
if [[ "$package" == 'ventoy' ]]; then
|
||||
for required_path in \
|
||||
/usr/bin/ventoy \
|
||||
'/opt/ventoy/VentoyGUI.*' \
|
||||
/opt/ventoy/boot/boot.img \
|
||||
/opt/ventoy/tool/VentoyWorker.sh \
|
||||
/opt/ventoy/ventoy/ventoy.disk.img.xz \
|
||||
/usr/share/applications/ventoy.desktop \
|
||||
/usr/share/icons/hicolor/128x128/apps/ventoy.png; do
|
||||
contains_path "$required_path" || {
|
||||
echo "${package}: отсутствует компонент Ventoy: ${required_path}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "$package" == 'yandex-browser-stable' ]]; then
|
||||
for command in cpio rpm2cpio; do
|
||||
command -v "$command" >/dev/null 2>&1 || {
|
||||
echo "${package}: для проверки desktop-файлов требуется ${command}" >&2
|
||||
exit 2
|
||||
}
|
||||
done
|
||||
for required_path in \
|
||||
/usr/bin/yandex-browser \
|
||||
/usr/bin/yandex-browser-stable \
|
||||
/opt/yandex/browser/yandex-browser \
|
||||
/opt/yandex/browser/yandex_browser \
|
||||
/opt/yandex/browser/yandex_browser-sandbox \
|
||||
/usr/share/appdata/yandex-browser.appdata.xml \
|
||||
/usr/share/applications/ru.yandex.desktop.browser.desktop \
|
||||
/usr/share/applications/yandex-browser.desktop \
|
||||
/usr/share/icons/hicolor/256x256/apps/yandex-browser.png \
|
||||
/usr/share/mime/packages/yandex-browser-yprotect.xml; do
|
||||
contains_path "$required_path" || {
|
||||
echo "${package}: отсутствует компонент Яндекс Браузера: ${required_path}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
contains_path '/etc/cron.daily/yandex-browser' && {
|
||||
echo "${package}: обнаружена upstream cron-задача обновления" >&2
|
||||
exit 1
|
||||
}
|
||||
contains_path '/etc/xdg/autostart/yandex-browser_user_setup.desktop' && {
|
||||
echo "${package}: обнаружен нежелательный upstream autostart" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
compatibility_desktop="$(
|
||||
set +o pipefail
|
||||
rpm2cpio "$artifact" |
|
||||
cpio -i --quiet --to-stdout \
|
||||
/usr/share/applications/yandex-browser.desktop
|
||||
)"
|
||||
canonical_desktop="$(
|
||||
set +o pipefail
|
||||
rpm2cpio "$artifact" |
|
||||
cpio -i --quiet --to-stdout \
|
||||
/usr/share/applications/ru.yandex.desktop.browser.desktop
|
||||
)"
|
||||
desktop_entry_hidden() {
|
||||
awk '
|
||||
$0 == "[Desktop Entry]" { in_entry = 1; next }
|
||||
/^\[/ { in_entry = 0 }
|
||||
in_entry && $0 == "NoDisplay=true" { found = 1 }
|
||||
END { exit !found }
|
||||
'
|
||||
}
|
||||
desktop_entry_hidden <<<"$compatibility_desktop" || {
|
||||
echo "${package}: совместимый desktop-id виден в меню приложений" >&2
|
||||
exit 1
|
||||
}
|
||||
if desktop_entry_hidden <<<"$canonical_desktop"; then
|
||||
echo "${package}: canonical desktop-id ошибочно скрыт" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
while read -r path _ _ _ mode _; do
|
||||
[[ "$path" == /usr/bin/* || "$path" == /usr/sbin/* ]] || continue
|
||||
[[ "$mode" == 0100755 || "$mode" == 0120000 ]] || {
|
||||
echo "${package}: неверные права ${mode} у ${path}" >&2
|
||||
exit 1
|
||||
}
|
||||
done < <(rpm -qp --dump "$artifact")
|
||||
|
||||
echo "OK: ${package} ($(basename "$artifact"), ${#payload[@]} путей)"
|
||||
done
|
||||
|
||||
echo "OK: payload проверен для ${#packages[@]} пакетов"
|
||||
Reference in New Issue
Block a user