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:
Александр
2026-07-25 00:49:32 +10:00
commit f405ae2bcf
168 changed files with 8483 additions and 0 deletions
+81
View File
@@ -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
+130
View File
@@ -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[@]} пакетов"
+110
View File
@@ -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"
'
}
+294
View File
@@ -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
+68
View File
@@ -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"
+49
View File
@@ -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 завершены'
+71
View File
@@ -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()
+448
View File
@@ -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[@]} пакетов"
+323
View File
@@ -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())
+330
View File
@@ -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[@]} пакетов"