diff --git a/.github/workflows/update-packages.yml b/.github/workflows/update-packages.yml index fb90bb4..848f6a8 100644 --- a/.github/workflows/update-packages.yml +++ b/.github/workflows/update-packages.yml @@ -1,6 +1,6 @@ # yamllint disable rule:document-start name: update-packages -'on': +"on": workflow_dispatch: schedule: - cron: "0 2 * * *" @@ -24,8 +24,8 @@ jobs: substituters = https://cache.nixos.org https://adam01110-nur.cachix.org/ trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= adam01110-nur.cachix.org-1:43B8awTREG19aQ20luDD9BkxijKG/Q7hf8voMzS1X9I= # yamllint enable rule:line-length - - name: Run nix-update for all packages - run: ./scripts/update-all-packages.sh + - name: Update packages + run: python3 -m updater - name: Import gpg key uses: crazy-max/ghaction-import-gpg@v7 with: diff --git a/.gitignore b/.gitignore index 120ccc2..d3d14dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ # keep-sorted start -result* .direnv +.mypy_cache +.pytest_cache +.ruff_cache .rumdl_cache +__pycache__/ +*.egg-info/ +*.py[cod] +result* # keep-sorted end diff --git a/README.md b/README.md index 5fffad7..c6e0692 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Formatting is configured through `treefmt-nix`. ## Automation - `build.yml`: evaluates the repository and builds cacheable outputs against `nixpkgs-unstable`, `nixos-unstable`, and `nixos-stable`. -- `update-packages.yml`: runs `./scripts/update-all-packages.sh` and opens a signed pull request. +- `update-packages.yml`: runs `python3 -m updater` and opens a signed pull request. - `update-flake-lock.yml`: refreshes `flake.lock` weekly. ## Layout diff --git a/flake/treefmt.nix b/flake/treefmt.nix index 98ccea5..e1a5082 100644 --- a/flake/treefmt.nix +++ b/flake/treefmt.nix @@ -10,6 +10,12 @@ statix.enable = true; # keep-sorted end + ruff-check = { + enable = true; + extendSelect = ["I"]; + }; + ruff-format.enable = true; + rumdl-format.enable = true; # keep-sorted start diff --git a/scripts/update-all-packages.sh b/scripts/update-all-packages.sh deleted file mode 100755 index 0639ae3..0000000 --- a/scripts/update-all-packages.sh +++ /dev/null @@ -1,768 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SYSTEM="${SYSTEM:-x86_64-linux}" -UPDATE_TIMEOUT="${UPDATE_TIMEOUT:-10m}" - -declare -a TEMP_BACKUP_FILES=() -JQ_BIN="" -CURL_BIN="" -ACTIVE_BACKUP_FILE="" -ACTIVE_FILE_PATH="" - -cleanup_temp_backups() { - local backup_file - - for backup_file in "${TEMP_BACKUP_FILES[@]}"; do - rm -f "${backup_file}" || true - done - - TEMP_BACKUP_FILES=() -} - -trap cleanup_temp_backups EXIT - -restore_active_backup() { - if [ -n "${ACTIVE_BACKUP_FILE}" ] && [ -n "${ACTIVE_FILE_PATH}" ]; then - cp "${ACTIVE_BACKUP_FILE}" "${ACTIVE_FILE_PATH}" || true - fi -} - -abort_update() { - restore_active_backup - cleanup_temp_backups - exit 130 -} - -trap abort_update INT TERM - -resolve_tool() { - local command_name=$1 - local package_attr=$2 - local path - local store_paths - local store_path - - path=$(command -v "${command_name}" || true) - if [ -n "${path}" ]; then - printf '%s' "${path}" - return 0 - fi - - store_paths=$(nix build --no-link --print-out-paths "nixpkgs#${package_attr}") - while IFS= read -r store_path; do - if [ -x "${store_path}/bin/${command_name}" ]; then - printf '%s/bin/%s' "${store_path}" "${command_name}" - return 0 - fi - done <<<"${store_paths}" - - printf 'Failed to resolve %s from nixpkgs#%s\n' "${command_name}" "${package_attr}" >&2 - return 1 -} - -ensure_jq() { - if [ -z "${JQ_BIN}" ]; then - JQ_BIN=$(resolve_tool jq jq) - fi -} - -ensure_curl() { - if [ -z "${CURL_BIN}" ]; then - CURL_BIN=$(resolve_tool curl curl) - fi -} - -json_query() { - ensure_jq - "${JQ_BIN}" "$@" -} - -curl_fetch() { - ensure_curl - "${CURL_BIN}" -fsSL "$@" -} - -run_with_timeout() { - local label=$1 - local status - shift - - if [ -z "${UPDATE_TIMEOUT}" ] || [ "${UPDATE_TIMEOUT}" = "0" ]; then - if "$@"; then - return 0 - else - return $? - fi - fi - - if ! command -v timeout >/dev/null 2>&1; then - if "$@"; then - return 0 - else - return $? - fi - fi - - if timeout --foreground "${UPDATE_TIMEOUT}" "$@"; then - return 0 - else - status=$? - fi - - if [ "${status}" -eq 124 ]; then - printf 'Timed out after %s: %s\n' "${UPDATE_TIMEOUT}" "${label}" >&2 - fi - - return "${status}" -} - -escape_nix_string() { - local value=$1 - - value=${value//\\/\\\\} - value=${value//\"/\\\"} - - printf '%s' "${value}" -} - -nix_eval_attrset() { - local source_kind=$1 - local attrset=$2 - local apply_expr=$3 - - if [ "${source_kind}" = "flake" ]; then - nix eval --raw ".#${attrset}" --apply "${apply_expr}" - else - nix eval --raw --file default.nix "${attrset}" --apply "${apply_expr}" - fi -} - -list_derivations() { - local source_kind=$1 - local attrset=$2 - - nix_eval_attrset "${source_kind}" "${attrset}" ' - pkgs: - builtins.concatStringsSep "\n" - (builtins.filter - (name: - let v = builtins.getAttr name pkgs; - in builtins.isAttrs v && v ? type && v.type == "derivation") - (builtins.attrNames pkgs))' -} - -list_file_attrsets() { - nix eval --raw --file default.nix --apply ' - f: - let - attrs = f {}; - isDerivation = v: builtins.isAttrs v && v ? type && v.type == "derivation"; - hasDerivationMembers = set: - builtins.any (name: isDerivation (builtins.getAttr name set)) (builtins.attrNames set); - in - builtins.concatStringsSep "\n" - (builtins.filter - (name: - let v = builtins.getAttr name attrs; - in builtins.isAttrs v && !isDerivation v && hasDerivationMembers v) - (builtins.attrNames attrs))' -} - -list_flake_attrsets() { - if nix eval --raw ".#packages.${SYSTEM}" --apply 'pkgs: ""' >/dev/null 2>&1; then - printf '%s\n' "packages.${SYSTEM}" - fi -} - -read_package_version() { - local source_kind=$1 - local attrset=$2 - local attr=$3 - local escaped_attr - - escaped_attr=$(escape_nix_string "${attr}") - nix_eval_attrset "${source_kind}" "${attrset}" " - pkgs: - let pkg = builtins.getAttr \"${escaped_attr}\" pkgs; - in if builtins.isAttrs pkg && pkg ? version then pkg.version else \"\"" 2>/dev/null || true -} - -read_package_src_url() { - local source_kind=$1 - local attrset=$2 - local attr=$3 - local escaped_attr - - escaped_attr=$(escape_nix_string "${attr}") - nix_eval_attrset "${source_kind}" "${attrset}" " - pkgs: - let pkg = builtins.getAttr \"${escaped_attr}\" pkgs; - in if builtins.isAttrs pkg && pkg ? src && pkg.src ? url then pkg.src.url else \"\"" 2>/dev/null || true -} - -github_repo_from_url() { - local url=$1 - local repo - - if [[ ! "${url}" =~ ^https://github\.com/([^/]+)/([^/]+)(/|$) ]]; then - return 1 - fi - - repo=${BASH_REMATCH[2]} - repo=${repo%.git} - - printf '%s %s' "${BASH_REMATCH[1]}" "${repo}" -} - -latest_release_prefix_for_package() { - local source_kind=$1 - local attrset=$2 - local attr=$3 - local baseline_prefix=$4 - local src_url - local repo_ref - local owner - local repo - local latest_tag - local latest_version - - src_url=$(read_package_src_url "${source_kind}" "${attrset}" "${attr}") - repo_ref=$(github_repo_from_url "${src_url}" || true) - if [ -z "${repo_ref}" ]; then - return 0 - fi - - read -r owner repo <<<"${repo_ref}" - latest_tag=$(latest_github_release_tag "${owner}" "${repo}") - latest_version=$(strip_tag_prefix "${latest_tag}" v) - - if [[ ! "${latest_version}" =~ ^[0-9] ]]; then - return 0 - fi - - if [ -n "${baseline_prefix}" ] && version_is_older "${latest_version}" "${baseline_prefix}"; then - return 0 - fi - - printf '%s' "${latest_version}" -} - -version_mode_for_version() { - local version=$1 - - if [[ "${version}" =~ (^|[-._])unstable($|[-._0-9]) ]] || [[ "${version}" =~ (^|[-._])git($|[-._0-9]) ]]; then - printf '%s' branch - else - printf '%s' stable - fi -} - -attribute_file_path() { - local source_kind=$1 - local attrset=$2 - local attr=$3 - local escaped_attr - local position - local file_path - local root_dir - local candidate - - escaped_attr=$(escape_nix_string "${attr}") - position=$(nix_eval_attrset "${source_kind}" "${attrset}" " - pkgs: - let pkg = builtins.getAttr \"${escaped_attr}\" pkgs; - in if builtins.isAttrs pkg && pkg ? meta && pkg.meta ? position then pkg.meta.position else \"\"" 2>/dev/null || true) - - if [ -z "${position}" ]; then - return 1 - fi - - file_path=${position%%:*} - if [ "${source_kind}" = "flake" ] && [[ "${file_path}" =~ ^/nix/store/[^/]+-source(/.*)$ ]]; then - file_path="${PWD}${BASH_REMATCH[1]}" - fi - - if [ -z "${file_path}" ] || [ ! -f "${file_path}" ]; then - return 1 - fi - - root_dir=$(dirname "${file_path}") - - candidate="${root_dir}/${attr}/default.nix" - if [ -f "${candidate}" ]; then - printf '%s' "${candidate}" - return 0 - fi - - candidate="${root_dir}/${attr}.nix" - if [ -f "${candidate}" ]; then - printf '%s' "${candidate}" - return 0 - fi - - printf '%s' "${file_path}" -} - -backup_file() { - local file_path=$1 - local backup_file - - backup_file=$(mktemp) - TEMP_BACKUP_FILES+=("${backup_file}") - cp "${file_path}" "${backup_file}" - printf '%s' "${backup_file}" -} - -cleanup_backup_file() { - local backup_file=${1:-} - - if [ -n "${backup_file}" ]; then - rm -f "${backup_file}" || true - fi -} - -restore_backup_file() { - local backup_file=$1 - local file_path=$2 - - if [ -n "${backup_file}" ]; then - cp "${backup_file}" "${file_path}" - fi -} - -begin_file_transaction() { - ACTIVE_FILE_PATH=$1 - ACTIVE_BACKUP_FILE=$2 -} - -end_file_transaction() { - ACTIVE_FILE_PATH="" - ACTIVE_BACKUP_FILE="" -} - -run_nix_update() { - local source_kind=$1 - local version_mode=$2 - local attr_path=$3 - - if [ "${source_kind}" = "flake" ]; then - run_with_timeout "nix-update ${attr_path}" nix run nixpkgs#nix-update -- --flake --use-github-releases --version="${version_mode}" "${attr_path}" - else - run_with_timeout "nix-update ${attr_path}" nix run nixpkgs#nix-update -- -f default.nix --version="${version_mode}" "${attr_path}" - fi -} - -normalize_unstable_version_format() { - local source_kind=$1 - local attrset=$2 - local attr=$3 - local file_path=$4 - local previous_version=$5 - local current_version=$6 - local prefix=0 - local release_prefix - local date_part - local escaped_prefix - - if [[ ! "${current_version}" =~ ^([A-Za-z0-9.+-]+-)?unstable-([0-9]{4}-[0-9]{2}-[0-9]{2})$ ]]; then - return 0 - fi - - date_part=${BASH_REMATCH[2]} - - if [[ "${previous_version}" =~ ^([A-Za-z0-9.+-]+)-unstable-[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then - prefix=${BASH_REMATCH[1]} - fi - - release_prefix=$(latest_release_prefix_for_package "${source_kind}" "${attrset}" "${attr}" "${prefix}") - if [ -n "${release_prefix}" ]; then - prefix=${release_prefix} - fi - - escaped_prefix=$(printf '%s' "${prefix}" | sed -e 's/[&|\\]/\\&/g') - - sed -E -i "0,/version = \"([^\"]+-)?unstable-([0-9]{4}-[0-9]{2}-[0-9]{2})\";/s|version = \"([^\"]+-)?unstable-([0-9]{4}-[0-9]{2}-[0-9]{2})\";|version = \"${escaped_prefix}-unstable-${date_part}\";|" "${file_path}" -} - -refresh_dependency_hash() { - local source_kind=$1 - local attr_path=$2 - local file_path=$3 - local build_output - local build_status - local dependency_hash - local escaped_hash - - if ! grep -Eq 'dependencyHash = "sha256-[^"]+";' "${file_path}"; then - return 0 - fi - - sed -E -i '0,/dependencyHash = "sha256-[^"]+";/s|dependencyHash = "sha256-[^"]+";|dependencyHash = lib.fakeHash;|' "${file_path}" - - set +e - if [ "${source_kind}" = "flake" ]; then - build_output=$(run_with_timeout "nix build ${attr_path}" nix build ".#${attr_path}" --no-link 2>&1) - else - build_output=$(run_with_timeout "nix-build ${attr_path}" nix-build -A "${attr_path}" --no-out-link 2>&1) - fi - build_status=$? - set -e - - if [ "${build_status}" -eq 0 ]; then - return 0 - fi - - dependency_hash=$(printf '%s\n' "${build_output}" | sed -n -E 's/^[[:space:]]*got:[[:space:]]*(sha256-[A-Za-z0-9+/=]+)$/\1/p') - if [ -z "${dependency_hash}" ]; then - printf 'Failed to refresh dependencyHash for %s\n' "${attr_path}" >&2 - printf '%s\n' "${build_output}" >&2 - return 1 - fi - - escaped_hash=$(printf '%s' "${dependency_hash}" | sed -e 's/[&|\\]/\\&/g') - sed -E -i "0,/dependencyHash = lib\.fakeHash;/s|dependencyHash = lib\.fakeHash;|dependencyHash = \"${escaped_hash}\";|" "${file_path}" -} - -version_is_older() { - local candidate=$1 - local baseline=$2 - local escaped_candidate - local escaped_baseline - local result - - escaped_candidate=$(escape_nix_string "${candidate}") - escaped_baseline=$(escape_nix_string "${baseline}") - result=$(nix eval --raw --expr "if builtins.compareVersions \"${escaped_candidate}\" \"${escaped_baseline}\" < 0 then \"1\" else \"0\"" 2>/dev/null || printf '0') - - [ "${result}" = "1" ] -} - -should_block_downgrade() { - local before_version=$1 - local after_version=$2 - - [ -n "${before_version}" ] && [ -n "${after_version}" ] && [[ "${before_version}" =~ ^v?[0-9] ]] && [[ "${after_version}" =~ ^v?[0-9] ]] -} - -postprocess_package_update() { - local source_kind=$1 - local attrset=$2 - local attr=$3 - local attr_path=$4 - local file_path=$5 - local backup_file=$6 - local before_version=$7 - local after_version=$8 - - normalize_unstable_version_format "${source_kind}" "${attrset}" "${attr}" "${file_path}" "${before_version}" "${after_version}" - - if [ -n "${backup_file}" ] && cmp -s "${backup_file}" "${file_path}"; then - return 0 - fi - - if ! refresh_dependency_hash "${source_kind}" "${attr_path}" "${file_path}"; then - restore_backup_file "${backup_file}" "${file_path}" - return 1 - fi -} - -package_has_manifest_updater() { - local file_path=$1 - local package_dir - local manifest - - package_dir=$(dirname "${file_path}") - for manifest in "${package_dir}"/*.json; do - if [ ! -e "${manifest}" ]; then - continue - fi - - if manifest_has_release_asset_updater "${manifest}"; then - return 0 - fi - done - - return 1 -} - -run_package_update() { - local source_kind=$1 - local attrset=$2 - local attr=$3 - local attr_path="${attrset}.${attr}" - local before_version - local after_version - local version_mode - local file_path - local backup_file="" - local update_status - - before_version=$(read_package_version "${source_kind}" "${attrset}" "${attr}") - version_mode=$(version_mode_for_version "${before_version}") - file_path=$(attribute_file_path "${source_kind}" "${attrset}" "${attr}" || true) - if [ -n "${file_path}" ] && package_has_manifest_updater "${file_path}"; then - printf 'Skipping %s; manifest updater owns %s\n' "${attr_path}" "${file_path}" - return 0 - fi - - if [ -n "${file_path}" ] && [ -f "${file_path}" ]; then - backup_file=$(backup_file "${file_path}") - begin_file_transaction "${file_path}" "${backup_file}" - fi - - set +e - run_nix_update "${source_kind}" "${version_mode}" "${attr_path}" - update_status=$? - set -e - - if [ "${update_status}" -ne 0 ]; then - restore_backup_file "${backup_file}" "${file_path}" - end_file_transaction - cleanup_backup_file "${backup_file}" - if [ "${update_status}" -eq 130 ]; then - exit 130 - fi - return 0 - fi - - after_version=$(read_package_version "${source_kind}" "${attrset}" "${attr}") - - if [ -n "${file_path}" ] && [ -f "${file_path}" ]; then - if ! postprocess_package_update "${source_kind}" "${attrset}" "${attr}" "${attr_path}" "${file_path}" "${backup_file}" "${before_version}" "${after_version}"; then - end_file_transaction - cleanup_backup_file "${backup_file}" - return 0 - fi - - after_version=$(read_package_version "${source_kind}" "${attrset}" "${attr}") - fi - - if [ -n "${backup_file}" ] && should_block_downgrade "${before_version}" "${after_version}" && version_is_older "${after_version}" "${before_version}"; then - printf 'Reverting apparent downgrade for %s (%s -> %s)\n' "${attr_path}" "${before_version}" "${after_version}" >&2 - restore_backup_file "${backup_file}" "${file_path}" - fi - - end_file_transaction - cleanup_backup_file "${backup_file}" -} - -run_attrset_updates() { - local source_kind=$1 - local attrset=$2 - local -a attrs=() - local attr - - mapfile -t attrs < <(list_derivations "${source_kind}" "${attrset}") - for attr in "${attrs[@]}"; do - if [ -n "${attr}" ]; then - run_package_update "${source_kind}" "${attrset}" "${attr}" - fi - done -} - -run_discovered_attrsets() { - local source_kind=$1 - local list_fn=$2 - local -a attrsets=() - local attrset - - mapfile -t attrsets < <("${list_fn}") - for attrset in "${attrsets[@]}"; do - if [ -n "${attrset}" ]; then - run_attrset_updates "${source_kind}" "${attrset}" - fi - done -} - -manifest_has_release_asset_updater() { - local manifest=$1 - - json_query -e '.updater.type == "github-release-assets"' "${manifest}" >/dev/null 2>&1 -} - -manifest_value() { - local manifest=$1 - local filter=$2 - - json_query -r "${filter} // empty" "${manifest}" -} - -latest_github_release_tag() { - local owner=$1 - local repo=$2 - local response - local status - - set +e - response=$(curl_fetch "https://api.github.com/repos/${owner}/${repo}/releases/latest" 2>/dev/null) - status=$? - set -e - - if [ "${status}" -ne 0 ]; then - return 0 - fi - - printf '%s\n' "${response}" | json_query -r '.tag_name // empty' -} - -strip_tag_prefix() { - local tag=$1 - local prefix=$2 - - if [ -n "${prefix}" ] && [[ "${tag}" == "${prefix}"* ]]; then - printf '%s' "${tag#"${prefix}"}" - else - printf '%s' "${tag}" - fi -} - -release_download_url() { - local manifest=$1 - local tag=$2 - local asset=$3 - local owner - local repo - local template - local url - - owner=$(manifest_value "${manifest}" '.updater.owner') - repo=$(manifest_value "${manifest}" '.updater.repo') - template=$(manifest_value "${manifest}" '.updater.urlTemplate') - - if [ -z "${template}" ]; then - template='https://github.com/{{owner}}/{{repo}}/releases/download/{{tag}}/{{asset}}' - fi - - url=${template//\{\{owner\}\}/${owner}} - url=${url//\{\{repo\}\}/${repo}} - url=${url//\{\{tag\}\}/${tag}} - url=${url//\{\{asset\}\}/${asset}} - - printf '%s' "${url}" -} - -prefetch_release_asset_hash() { - local manifest=$1 - local tag=$2 - local system=$3 - local asset - local url - - # shellcheck disable=SC2016 - asset=$(json_query -r --arg system "${system}" '.updater.assets[$system] // empty' "${manifest}") - if [ -z "${asset}" ]; then - printf 'Missing asset mapping for %s in %s\n' "${system}" "${manifest}" >&2 - return 1 - fi - - url=$(release_download_url "${manifest}" "${tag}" "${asset}") - run_with_timeout "prefetch ${url}" nix store prefetch-file --json --hash-type sha256 "${url}" | json_query -r '.hash' -} - -prefetch_release_asset_hashes() { - local manifest=$1 - local tag=$2 - local hashes_file=$3 - local -a systems=() - local system - local hash - local next_file - - mapfile -t systems < <(json_query -r '.updater.assets | keys[]' "${manifest}") - printf '{}\n' >"${hashes_file}" - - for system in "${systems[@]}"; do - hash=$(prefetch_release_asset_hash "${manifest}" "${tag}" "${system}") - next_file=$(mktemp) - TEMP_BACKUP_FILES+=("${next_file}") - # shellcheck disable=SC2016 - json_query --arg system "${system}" --arg hash "${hash}" '. + {($system): $hash}' "${hashes_file}" >"${next_file}" - mv "${next_file}" "${hashes_file}" - done -} - -write_release_asset_manifest() { - local manifest=$1 - local version=$2 - local hashes_file=$3 - local next_file - - next_file=$(mktemp) - TEMP_BACKUP_FILES+=("${next_file}") - # shellcheck disable=SC2016 - json_query --arg version "${version}" --slurpfile hashes "${hashes_file}" '.version = $version | .hashes = $hashes[0]' "${manifest}" >"${next_file}" - mv "${next_file}" "${manifest}" -} - -update_release_asset_manifest() { - local manifest=$1 - local owner - local repo - local tag_prefix - local current_version - local latest_tag - local latest_version - local hashes_file - - if ! manifest_has_release_asset_updater "${manifest}"; then - return 0 - fi - - owner=$(manifest_value "${manifest}" '.updater.owner') - repo=$(manifest_value "${manifest}" '.updater.repo') - tag_prefix=$(manifest_value "${manifest}" '.updater.tagPrefix') - current_version=$(manifest_value "${manifest}" '.version') - - if [ -z "${owner}" ] || [ -z "${repo}" ]; then - printf 'Skipping incomplete release asset manifest: %s\n' "${manifest}" >&2 - return 0 - fi - - latest_tag=$(latest_github_release_tag "${owner}" "${repo}") - latest_version=$(strip_tag_prefix "${latest_tag}" "${tag_prefix}") - - if [ -z "${latest_version}" ]; then - printf 'Failed to determine latest release version for %s/%s\n' "${owner}" "${repo}" >&2 - return 1 - fi - - if should_block_downgrade "${current_version}" "${latest_version}" && version_is_older "${latest_version}" "${current_version}"; then - printf 'Skipping apparent downgrade for %s (%s -> %s)\n' "${manifest}" "${current_version}" "${latest_version}" >&2 - return 0 - fi - - if [ "${current_version}" = "${latest_version}" ]; then - printf '%s already up to date at %s\n' "${manifest}" "${current_version}" - return 0 - fi - - printf 'Updating %s from %s to %s\n' "${manifest}" "${current_version}" "${latest_version}" - - hashes_file=$(mktemp) - TEMP_BACKUP_FILES+=("${hashes_file}") - prefetch_release_asset_hashes "${manifest}" "${latest_tag}" "${hashes_file}" - write_release_asset_manifest "${manifest}" "${latest_version}" "${hashes_file}" -} - -list_release_asset_manifests() { - find pkgs -name '*.json' -type f | sort -} - -update_release_asset_manifests() { - local manifest - - while IFS= read -r manifest; do - update_release_asset_manifest "${manifest}" - done < <(list_release_asset_manifests) -} - -main() { - run_discovered_attrsets flake list_flake_attrsets - run_discovered_attrsets file list_file_attrsets - update_release_asset_manifests -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi diff --git a/updater/__main__.py b/updater/__main__.py new file mode 100644 index 0000000..855590e --- /dev/null +++ b/updater/__main__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/updater/cli.py b/updater/cli.py new file mode 100644 index 0000000..762d613 --- /dev/null +++ b/updater/cli.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import argparse +import os + +from .discovery import discover_packages, filter_packages +from .manifest import list_release_asset_manifests, update_release_asset_manifest +from .models import UpdateResult +from .package_backend import update_package +from .report import print_report + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Update NUR packages with per-package validation") + parser.add_argument("--system", default=os.environ.get("SYSTEM", "x86_64-linux")) + parser.add_argument("--package", action="append", default=[]) + parser.add_argument("--backend", choices=["nix-update", "manifest", "all"], default="all") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--report", choices=["text", "json"], default="text") + parser.add_argument("--fail-on-invalid", action="store_true") + parser.add_argument("--timeout", default=os.environ.get("UPDATE_TIMEOUT", "10m")) + args = parser.parse_args(argv) + + results: list[UpdateResult] = [] + if args.backend in {"nix-update", "all"}: + refs = filter_packages(discover_packages(args.system), args.package) + for ref in refs: + results.append(update_package(ref, dry_run=args.dry_run, timeout=args.timeout)) + + if args.backend in {"manifest", "all"}: + for manifest in list_release_asset_manifests(): + if args.package and not any(name in str(manifest) for name in args.package): + continue + results.append(update_release_asset_manifest(manifest, dry_run=args.dry_run)) + + print_report(results, args.report) + if any(result.status == "failed" for result in results): + return 1 + if args.fail_on_invalid and any(result.status == "invalid" for result in results): + return 1 + return 0 diff --git a/updater/discovery.py b/updater/discovery.py new file mode 100644 index 0000000..df1e209 --- /dev/null +++ b/updater/discovery.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from collections.abc import Iterable + +from .models import PackageRef, SourceKind +from .nix import attr_file_path, flake_attrsets, list_derivations, list_file_attrsets + + +def discover_packages(system: str) -> list[PackageRef]: + refs: list[PackageRef] = [] + for attrset in flake_attrsets(system): + refs.extend(_refs_for_attrset("flake", attrset)) + for attrset in list_file_attrsets(): + refs.extend(_refs_for_attrset("file", attrset)) + return refs + + +def filter_packages(refs: Iterable[PackageRef], selected: list[str]) -> list[PackageRef]: + if not selected: + return list(refs) + wanted = set(selected) + return [ref for ref in refs if ref.attr_path in wanted or ref.attr in wanted] + + +def _refs_for_attrset(source_kind: SourceKind, attrset: str) -> list[PackageRef]: + refs: list[PackageRef] = [] + for attr in list_derivations(source_kind, attrset): + file_path = attr_file_path(source_kind, attrset, attr) + if not file_path: + continue + refs.append( + PackageRef( + source_kind=source_kind, + attrset=attrset, + attr=attr, + attr_path=f"{attrset}.{attr}", + file_path=file_path, + ) + ) + return refs diff --git a/updater/manifest.py b/updater/manifest.py new file mode 100644 index 0000000..3bc396c --- /dev/null +++ b/updater/manifest.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json +import tempfile +import urllib.request +from pathlib import Path +from typing import Any + +from .models import UpdateResult +from .process import ROOT, run_json +from .versions import should_block_downgrade, version_is_older + +DEFAULT_URL_TEMPLATE = "https://github.com/{{owner}}/{{repo}}/releases/download/{{tag}}/{{asset}}" + + +def manifest_has_release_asset_updater(path: Path) -> bool: + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return False + return data.get("updater", {}).get("type") == "github-release-assets" + + +def package_has_manifest_updater(file_path: Path) -> Path | None: + for manifest in sorted(file_path.parent.glob("*.json")): + if manifest_has_release_asset_updater(manifest): + return manifest + return None + + +def list_release_asset_manifests() -> list[Path]: + return sorted( + path + for path in (ROOT / "pkgs").glob("**/*.json") + if manifest_has_release_asset_updater(path) + ) + + +def latest_github_release_tag(owner: str, repo: str) -> str | None: + url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" + try: + with urllib.request.urlopen(url, timeout=30) as response: + data = json.loads(response.read().decode()) + except OSError: + return None + return data.get("tag_name") or None + + +def latest_release_prefix_for_url(src_url: str | None) -> str | None: + if not src_url or not src_url.startswith("https://github.com/"): + return None + parts = src_url.removesuffix(".git").split("/") + if len(parts) < 5: + return None + tag = latest_github_release_tag(parts[3], parts[4]) + if not tag: + return None + return strip_tag_prefix(tag, "v") + + +def update_release_asset_manifest(manifest: Path, *, dry_run: bool = False) -> UpdateResult: + data = _read_manifest(manifest) + updater = data.get("updater", {}) + owner = updater.get("owner") + repo = updater.get("repo") + tag_prefix = updater.get("tagPrefix", "") + current = data.get("version", "") + assets = updater.get("assets") or {} + + if not owner or not repo or not assets: + return UpdateResult( + str(manifest.relative_to(ROOT)), + "skipped", + "incomplete release asset manifest", + ) + + latest_tag = latest_github_release_tag(owner, repo) + latest = strip_tag_prefix(latest_tag or "", tag_prefix) + if not latest: + return UpdateResult( + str(manifest.relative_to(ROOT)), + "failed", + f"failed to determine latest release for {owner}/{repo}", + ) + if should_block_downgrade(current, latest) and version_is_older(latest, current): + return UpdateResult( + str(manifest.relative_to(ROOT)), + "skipped", + f"apparent downgrade {current} -> {latest}", + ) + if current == latest: + return UpdateResult( + str(manifest.relative_to(ROOT)), + "skipped", + f"already up to date at {current}", + ) + + hashes = _prefetch_asset_hashes(data, latest_tag or latest) + if dry_run: + return UpdateResult( + str(manifest.relative_to(ROOT)), + "updated", + f"manifest {current} -> {latest} (dry-run)", + ) + + updated = dict(data) + updated["version"] = latest + updated["hashes"] = hashes + _atomic_write_json(manifest, updated) + return UpdateResult( + str(manifest.relative_to(ROOT)), + "updated", + f"release asset manifest {current} -> {latest}", + [manifest], + ) + + +def strip_tag_prefix(tag: str, prefix: str) -> str: + return tag.removeprefix(prefix) if prefix and tag.startswith(prefix) else tag + + +def render_asset_url(data: dict[str, Any], tag: str, asset: str) -> str: + updater = data.get("updater", {}) + template = updater.get("urlTemplate") or DEFAULT_URL_TEMPLATE + return ( + template.replace("{{owner}}", updater.get("owner", "")) + .replace("{{repo}}", updater.get("repo", "")) + .replace("{{tag}}", tag) + .replace("{{asset}}", asset) + ) + + +def _prefetch_asset_hashes(data: dict[str, Any], tag: str) -> dict[str, str]: + hashes: dict[str, str] = {} + for system, asset in sorted(data.get("updater", {}).get("assets", {}).items()): + url = render_asset_url(data, tag, asset) + result = run_json(["nix", "store", "prefetch-file", "--json", "--hash-type", "sha256", url]) + hashes[system] = result["hash"] + return hashes + + +def _read_manifest(manifest: Path) -> dict[str, Any]: + return json.loads(manifest.read_text()) + + +def _atomic_write_json(path: Path, data: dict[str, Any]) -> None: + text = json.dumps(data, indent=2) + "\n" + with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False) as tmp: + tmp.write(text) + tmp_path = Path(tmp.name) + tmp_path.replace(path) diff --git a/updater/models.py b/updater/models.py new file mode 100644 index 0000000..dc5fedc --- /dev/null +++ b/updater/models.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +VersionMode = Literal["stable", "branch"] +SourceKind = Literal["flake", "file"] +ResultStatus = Literal["updated", "skipped", "invalid", "failed"] + + +@dataclass(frozen=True) +class PackageRef: + source_kind: SourceKind + attrset: str + attr: str + attr_path: str + file_path: Path + + +@dataclass(frozen=True) +class PackageState: + version: str + version_mode: VersionMode + src_url: str | None = None + src_rev: str | None = None + src_hash: str | None = None + dependency_hash: str | None = None + + +@dataclass(frozen=True) +class UpdateResult: + name: str + status: ResultStatus + reason: str + changed_files: list[Path] = field(default_factory=list) + + +@dataclass(frozen=True) +class ValidationResult: + accepted: bool + reason: str + dependency_hash_refresh_allowed: bool = False diff --git a/updater/nix.py b/updater/nix.py new file mode 100644 index 0000000..cd28733 --- /dev/null +++ b/updater/nix.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path + +from .models import PackageState, SourceKind +from .process import ROOT, run +from .versions import version_mode + + +def nix_eval_attrset(source_kind: SourceKind, attrset: str, apply_expr: str) -> str: + if source_kind == "flake": + command = ["nix", "eval", "--raw", f".#{attrset}", "--apply", apply_expr] + else: + command = [ + "nix", + "eval", + "--raw", + "--file", + "default.nix", + attrset, + "--apply", + apply_expr, + ] + return run(command).stdout + + +def list_derivations(source_kind: SourceKind, attrset: str) -> list[str]: + output = nix_eval_attrset( + source_kind, + attrset, + r""" +pkgs: + builtins.concatStringsSep "\n" + (builtins.filter + (name: + let v = builtins.getAttr name pkgs; + in builtins.isAttrs v && v ? type && v.type == "derivation") + (builtins.attrNames pkgs)) +""", + ) + return [line for line in output.splitlines() if line] + + +def list_file_attrsets() -> list[str]: + output = run( + [ + "nix", + "eval", + "--raw", + "--file", + "default.nix", + "--apply", + r""" +f: +let + attrs = f {}; + isDerivation = v: builtins.isAttrs v && v ? type && v.type == "derivation"; + hasDerivationMembers = set: + builtins.any (name: isDerivation (builtins.getAttr name set)) (builtins.attrNames set); +in + builtins.concatStringsSep "\n" + (builtins.filter + (name: + let v = builtins.getAttr name attrs; + in builtins.isAttrs v && !isDerivation v && hasDerivationMembers v) + (builtins.attrNames attrs)) +""", + ] + ).stdout + return [line for line in output.splitlines() if line] + + +def flake_attrsets(system: str) -> list[str]: + attrset = f"packages.{system}" + result = run(["nix", "eval", "--raw", f".#{attrset}", "--apply", 'pkgs: ""'], check=False) + return [attrset] if result.returncode == 0 else [] + + +def read_state(source_kind: SourceKind, attrset: str, attr: str) -> PackageState: + escaped = _escape(attr) + output = nix_eval_attrset( + source_kind, + attrset, + f''' +pkgs: + let + pkg = builtins.getAttr "{escaped}" pkgs; + src = if builtins.isAttrs pkg && pkg ? src then pkg.src else {{}}; + in builtins.toJSON {{ + version = if builtins.isAttrs pkg && pkg ? version then pkg.version else ""; + srcUrl = if builtins.isAttrs src && src ? url then src.url else null; + srcRev = if builtins.isAttrs src && src ? rev then src.rev else null; + srcHash = if builtins.isAttrs src && src ? outputHash then src.outputHash else null; + dependencyHash = if builtins.isAttrs pkg && pkg ? dependencyHash then pkg.dependencyHash else null; + }} +''', + ) + data = json.loads(output) + version = data.get("version") or "" + return PackageState( + version=version, + version_mode=version_mode(version), + src_url=data.get("srcUrl"), + src_rev=data.get("srcRev"), + src_hash=data.get("srcHash"), + dependency_hash=data.get("dependencyHash"), + ) + + +def attr_file_path(source_kind: SourceKind, attrset: str, attr: str) -> Path | None: + escaped = _escape(attr) + position = nix_eval_attrset( + source_kind, + attrset, + f''' +pkgs: + let pkg = builtins.getAttr "{escaped}" pkgs; + in if builtins.isAttrs pkg && pkg ? meta && pkg.meta ? position then pkg.meta.position else "" +''', + ).strip() + if not position: + return None + + file_name = position.split(":", 1)[0] + if source_kind == "flake": + match = re.match(r"^/nix/store/[^/]+-source(/.*)$", file_name) + if match: + file_name = str(ROOT) + match.group(1) + + file_path = Path(file_name) + if not file_path.is_file(): + return None + + root_dir = file_path.parent + for candidate in (root_dir / attr / "default.nix", root_dir / f"{attr}.nix"): + if candidate.is_file(): + return candidate + return file_path + + +def _escape(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') diff --git a/updater/package_backend.py b/updater/package_backend.py new file mode 100644 index 0000000..f304548 --- /dev/null +++ b/updater/package_backend.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from .manifest import latest_release_prefix_for_url, package_has_manifest_updater +from .models import PackageRef, PackageState, ResultStatus, UpdateResult +from .nix import read_state +from .process import CommandError, run +from .transactions import FileTransaction, paths_owned_by +from .validation import validate_transition +from .versions import branch_parts + + +def update_package( + ref: PackageRef, *, dry_run: bool = False, timeout: str | None = None +) -> UpdateResult: + manifest = package_has_manifest_updater(ref.file_path) + if manifest: + return UpdateResult(ref.attr_path, "skipped", f"manifest updater owns {manifest}") + + owned_roots = package_owned_roots(ref.file_path) + before = read_state(ref.source_kind, ref.attrset, ref.attr) + + with FileTransaction(owned_roots) as transaction: + try: + _run_nix_update(ref, before.version_mode, timeout=timeout) + except CommandError as error: + transaction.restore() + return UpdateResult(ref.attr_path, "skipped", f"nix-update failed: {error}") + + after = read_state(ref.source_kind, ref.attrset, ref.attr) + changed = transaction.new_changed_files() + owned_changed, unrelated = paths_owned_by(changed, owned_roots) + release_prefix = latest_release_prefix_for_url(after.src_url) + if _preserve_unproven_branch_prefix(ref.file_path, before, after, release_prefix): + after = read_state(ref.source_kind, ref.attrset, ref.attr) + changed = transaction.new_changed_files() + owned_changed, unrelated = paths_owned_by(changed, owned_roots) + validation = validate_transition( + before, + after, + package_files_changed=bool(owned_changed), + unrelated_files_changed=bool(unrelated), + release_prefix=release_prefix, + ) + if not validation.accepted: + transaction.restore() + return UpdateResult( + ref.attr_path, _rejected_status(validation.reason), validation.reason + ) + + if validation.dependency_hash_refresh_allowed: + try: + _refresh_dependency_hash(ref, timeout=timeout) + except CommandError as error: + transaction.restore() + return UpdateResult( + ref.attr_path, "failed", f"dependencyHash refresh failed: {error}" + ) + + changed_after_refresh = transaction.new_changed_files() + owned_changed, unrelated = paths_owned_by(changed_after_refresh, owned_roots) + if unrelated: + transaction.restore() + return UpdateResult( + ref.attr_path, + "invalid", + "hash refresh changed files outside package ownership", + ) + + if dry_run: + transaction.restore() + return UpdateResult( + ref.attr_path, + "updated", + f"{validation.reason} (dry-run)", + sorted(owned_changed), + ) + return UpdateResult(ref.attr_path, "updated", validation.reason, sorted(owned_changed)) + + +def package_owned_roots(file_path: Path) -> list[Path]: + roots = [file_path] + if file_path.name == "default.nix": + roots.append(file_path.parent) + roots.extend(sorted(file_path.parent.glob("*.json"))) + return roots + + +def _preserve_unproven_branch_prefix( + file_path: Path, + before: PackageState, + after: PackageState, + release_prefix: str | None, +) -> bool: + before_parts = branch_parts(before.version) + after_parts = branch_parts(after.version) + if not before_parts or not after_parts: + return False + + before_prefix, _ = before_parts + after_prefix, after_date = after_parts + source_changed = before.src_rev != after.src_rev or before.src_url != after.src_url + if not source_changed or before_prefix == after_prefix or after_prefix == release_prefix: + return False + + version = f"{before_prefix}-unstable-{after_date}" + text = file_path.read_text() + updated = re.sub(r'version = "[^"]+";', f'version = "{version}";', text, count=1) + if updated == text: + return False + file_path.write_text(updated) + return True + + +def _rejected_status(reason: str) -> ResultStatus: + skipped_prefixes = ( + "no package-owned files changed", + "rejected apparent downgrade", + "rejected branch version change without source change", + "rejected dependency-hash-only diff", + "rejected version-only branch prefix change", + ) + return "skipped" if reason.startswith(skipped_prefixes) else "invalid" + + +def _run_nix_update(ref: PackageRef, version_mode: str, *, timeout: str | None) -> None: + if ref.source_kind == "flake": + command = [ + "nix", + "run", + "nixpkgs#nix-update", + "--", + "--flake", + "--use-github-releases", + f"--version={version_mode}", + ref.attr_path, + ] + else: + command = [ + "nix", + "run", + "nixpkgs#nix-update", + "--", + "-f", + "default.nix", + f"--version={version_mode}", + ref.attr_path, + ] + run(command, timeout=timeout) + + +def _refresh_dependency_hash(ref: PackageRef, *, timeout: str | None) -> None: + text = ref.file_path.read_text() + if not re.search(r'dependencyHash = "sha256-[^"]+";', text): + return + ref.file_path.write_text( + re.sub( + r'dependencyHash = "sha256-[^"]+";', + "dependencyHash = lib.fakeHash;", + text, + count=1, + ) + ) + if ref.source_kind == "flake": + result = run( + ["nix", "build", f".#{ref.attr_path}", "--no-link"], + timeout=timeout, + check=False, + ) + else: + result = run( + ["nix-build", "-A", ref.attr_path, "--no-out-link"], + timeout=timeout, + check=False, + ) + if result.returncode == 0: + return + match = re.search(r"^\s*got:\s*(sha256-[A-Za-z0-9+/=]+)$", result.stderr + result.stdout, re.M) + if not match: + raise CommandError(["refresh-dependency-hash", ref.attr_path], result) + ref.file_path.write_text( + re.sub( + r"dependencyHash = lib\.fakeHash;", + f'dependencyHash = "{match.group(1)}";', + ref.file_path.read_text(), + count=1, + ) + ) diff --git a/updater/process.py b/updater/process.py new file mode 100644 index 0000000..9bd04dc --- /dev/null +++ b/updater/process.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] + + +class CommandError(RuntimeError): + def __init__(self, command: list[str], result: subprocess.CompletedProcess[str]) -> None: + self.command = command + self.result = result + super().__init__(result.stderr.strip() or result.stdout.strip() or "command failed") + + +def run( + command: list[str], + *, + check: bool = True, + timeout: str | None = None, + cwd: Path = ROOT, +) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + actual = command + if timeout and timeout != "0": + actual = ["timeout", "--foreground", timeout, *command] + + result = subprocess.run( + actual, + cwd=cwd, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if check and result.returncode != 0: + raise CommandError(actual, result) + return result + + +def run_json(command: list[str], **kwargs: Any) -> Any: + return json.loads(run(command, **kwargs).stdout) diff --git a/updater/pyproject.toml b/updater/pyproject.toml new file mode 100644 index 0000000..8f3f56f --- /dev/null +++ b/updater/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "nur" +version = "0.0.0" +requires-python = ">=3.13" + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +extend-select = ["I"] + +[tool.ty.environment] +extra-paths = [".."] + +[tool.ty.src] +include = ["."] + +[tool.uv] +package = false diff --git a/updater/report.py b/updater/report.py new file mode 100644 index 0000000..8525298 --- /dev/null +++ b/updater/report.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import json + +from .models import UpdateResult + + +def print_report(results: list[UpdateResult], report_format: str) -> None: + if report_format == "json": + print( + json.dumps( + [ + { + "package": result.name, + "status": result.status, + "reason": result.reason, + "changedFiles": [str(path) for path in result.changed_files], + } + for result in results + ], + indent=2, + ) + ) + return + + rows = [("Package", "Result", "Reason")] + rows.extend((result.name, result.status, result.reason) for result in results) + widths = [max(len(row[index]) for row in rows) for index in range(3)] + for index, row in enumerate(rows): + print(" ".join(value.ljust(widths[column]) for column, value in enumerate(row))) + if index == 0: + print(" ".join("-" * width for width in widths)) diff --git a/updater/transactions.py b/updater/transactions.py new file mode 100644 index 0000000..1206c2c --- /dev/null +++ b/updater/transactions.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path + +from .process import ROOT, run + + +class FileTransaction: + def __init__(self, owned_paths: list[Path]) -> None: + self.owned_paths = sorted({path.resolve() for path in owned_paths if path.exists()}) + self._snapshot_files = _files_under(self.owned_paths) + self._tmpdir: Path | None = None + self._backups: dict[Path, Path] = {} + self.before_diff = git_changed_files() + + def __enter__(self) -> FileTransaction: + self._tmpdir = Path(tempfile.mkdtemp(prefix="update-package-")) + for path in self._snapshot_files: + backup = self._tmpdir / str(path.relative_to(ROOT)).replace("/", "__") + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, backup) + self._backups[path] = backup + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + if self._tmpdir: + shutil.rmtree(self._tmpdir, ignore_errors=True) + + def new_changed_files(self) -> set[Path]: + return git_changed_files() - self.before_diff + + def restore(self) -> None: + before = set(self._backups) + for path in _files_under(self.owned_paths): + if path not in before: + path.unlink() + for path, backup in self._backups.items(): + shutil.copy2(backup, path) + + +def git_changed_files() -> set[Path]: + result = run(["git", "diff", "--name-only"], check=False) + return {ROOT / line for line in result.stdout.splitlines() if line} + + +def paths_owned_by(changed: set[Path], owned_roots: list[Path]) -> tuple[set[Path], set[Path]]: + owned: set[Path] = set() + unrelated: set[Path] = set() + roots = [path.resolve() for path in owned_roots] + for path in changed: + resolved = path.resolve() + if any(resolved == root or root in resolved.parents for root in roots): + owned.add(path) + else: + unrelated.add(path) + return owned, unrelated + + +def _files_under(paths: list[Path]) -> list[Path]: + files: set[Path] = set() + for path in paths: + if path.is_file(): + files.add(path.resolve()) + elif path.is_dir(): + files.update(child.resolve() for child in path.rglob("*") if child.is_file()) + return sorted(files) diff --git a/updater/validation.py b/updater/validation.py new file mode 100644 index 0000000..333d8f8 --- /dev/null +++ b/updater/validation.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from .models import PackageState, ValidationResult +from .versions import branch_parts, should_block_downgrade, version_is_older + + +def validate_transition( + before: PackageState, + after: PackageState, + *, + package_files_changed: bool, + unrelated_files_changed: bool, + release_prefix: str | None = None, +) -> ValidationResult: + if unrelated_files_changed: + return ValidationResult(False, "backend changed files outside package ownership") + if not package_files_changed: + return ValidationResult(False, "no package-owned files changed") + + source_changed = before.src_rev != after.src_rev or before.src_url != after.src_url + dependency_only = ( + before.dependency_hash != after.dependency_hash + and before.version == after.version + and before.src_rev == after.src_rev + and before.src_url == after.src_url + and before.src_hash == after.src_hash + ) + if dependency_only: + return ValidationResult( + False, "rejected dependency-hash-only diff after no-op source update" + ) + + if should_block_downgrade(before.version, after.version) and version_is_older( + after.version, before.version + ): + return ValidationResult( + False, f"rejected apparent downgrade {before.version} -> {after.version}" + ) + + before_branch = branch_parts(before.version) + after_branch = branch_parts(after.version) + if before.version_mode == "branch" or after.version_mode == "branch": + if not before_branch or not after_branch: + return ValidationResult(False, "unrecognized branch version format") + before_prefix, _ = before_branch + after_prefix, _ = after_branch + prefix_changed = before_prefix != after_prefix + + if prefix_changed and not source_changed: + return ValidationResult(False, "rejected version-only branch prefix change") + if prefix_changed and after_prefix != release_prefix: + return ValidationResult(False, "rejected unproven branch release prefix change") + if before.version != after.version and not source_changed: + return ValidationResult(False, "rejected branch version change without source change") + + return ValidationResult( + True, + "accepted source/package transition" if source_changed else "accepted package transition", + dependency_hash_refresh_allowed=source_changed, + ) diff --git a/updater/versions.py b/updater/versions.py new file mode 100644 index 0000000..1f46d0b --- /dev/null +++ b/updater/versions.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import re + +from .models import VersionMode +from .process import run + +BRANCH_RE = re.compile(r"^(?:(?P.+)-)?unstable-(?P\d{4}-\d{2}-\d{2})$") +NUMERIC_RE = re.compile(r"^v?\d") + + +def version_mode(version: str) -> VersionMode: + if re.search(r"(^|[-._])(?:unstable|git)($|[-._0-9])", version): + return "branch" + return "stable" + + +def branch_parts(version: str) -> tuple[str, str] | None: + match = BRANCH_RE.match(version) + if not match: + return None + return (match.group("prefix") or "0", match.group("date")) + + +def should_block_downgrade(before: str, after: str) -> bool: + return bool(before and after and NUMERIC_RE.match(before) and NUMERIC_RE.match(after)) + + +def version_is_older(candidate: str, baseline: str) -> bool: + expr = ( + f'if builtins.compareVersions "{_escape(candidate)}" "{_escape(baseline)}" < 0 ' + 'then "1" else "0"' + ) + result = run(["nix", "eval", "--raw", "--expr", expr], check=False) + return result.stdout.strip() == "1" + + +def _escape(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"')