From 77bb0cd2b094bd8dec02152e1fea4220ac125c10 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 1 May 2026 03:14:22 -0600 Subject: [PATCH] chore(control-plane): move shared automation to aio-fleet --- .aio-fleet.yml | 30 ++ .github/FUNDING.yml | 3 - .github/ISSUE_TEMPLATE/bug_report.yml | 30 -- .github/ISSUE_TEMPLATE/config.yml | 5 - .github/ISSUE_TEMPLATE/feature_request.yml | 25 -- .github/ISSUE_TEMPLATE/installation_help.yml | 32 -- .github/pull_request_template.md | 16 - .github/workflows/build.yml | 110 ----- .github/workflows/check-upstream.yml | 27 -- .github/workflows/publish-release.yml | 19 - .github/workflows/release.yml | 20 - .trunk/.gitignore | 9 - .trunk/configs/.hadolint.yaml | 4 - .trunk/configs/.isort.cfg | 2 - .trunk/configs/.markdownlint.yaml | 4 - .trunk/configs/.shellcheckrc | 6 - .trunk/configs/.yamllint.yaml | 7 - .trunk/configs/ruff.toml | 5 - .trunk/trunk.yaml | 14 - README.md | 79 ++-- SECURITY.md | 26 -- cliff.toml | 44 -- docs/customization-guide.md | 12 +- docs/release-checklist.md | 8 +- docs/releases.md | 11 +- docs/repo-settings.md | 41 +- docs/suite-components.md | 78 +--- docs/upstream-tracking.md | 95 +---- renovate.json | 26 -- scripts/check-upstream.py | 392 ------------------ scripts/components.py | 236 ----------- scripts/release.py | 42 -- scripts/update-template-changes.py | 150 ------- scripts/validate-derived-repo.sh | 42 -- scripts/validate-template.py | 207 --------- .../template/test_update_template_changes.py | 55 --- tests/template/test_validate_derived_repo.py | 16 - tests/template/test_validate_template.py | 20 - tests/unit/test_components.py | 88 ---- tests/unit/test_release_shim.py | 20 - upstream.toml | 15 - 41 files changed, 108 insertions(+), 1963 deletions(-) create mode 100644 .aio-fleet.yml delete mode 100644 .github/FUNDING.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 .github/ISSUE_TEMPLATE/installation_help.yml delete mode 100644 .github/pull_request_template.md delete mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/check-upstream.yml delete mode 100644 .github/workflows/publish-release.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 .trunk/.gitignore delete mode 100644 .trunk/configs/.hadolint.yaml delete mode 100644 .trunk/configs/.isort.cfg delete mode 100644 .trunk/configs/.markdownlint.yaml delete mode 100644 .trunk/configs/.shellcheckrc delete mode 100644 .trunk/configs/.yamllint.yaml delete mode 100644 .trunk/configs/ruff.toml delete mode 100644 .trunk/trunk.yaml delete mode 100644 SECURITY.md delete mode 100644 cliff.toml delete mode 100644 renovate.json delete mode 100644 scripts/check-upstream.py delete mode 100644 scripts/components.py delete mode 100755 scripts/release.py delete mode 100644 scripts/update-template-changes.py delete mode 100755 scripts/validate-derived-repo.sh delete mode 100644 scripts/validate-template.py delete mode 100644 tests/template/test_update_template_changes.py delete mode 100644 tests/template/test_validate_derived_repo.py delete mode 100644 tests/template/test_validate_template.py delete mode 100644 tests/unit/test_components.py delete mode 100644 tests/unit/test_release_shim.py delete mode 100644 upstream.toml diff --git a/.aio-fleet.yml b/.aio-fleet.yml new file mode 100644 index 0000000..649d2ac --- /dev/null +++ b/.aio-fleet.yml @@ -0,0 +1,30 @@ +schema_version: 1 +repo: unraid-aio-template +github_repo: JSONbored/unraid-aio-template +app_slug: unraid-aio-template +image: + name: jsonbored/unraid-aio-template + cache_scope: unraid-aio-template-image + pytest_tag: aio-template:pytest + publish_platforms: linux/amd64,linux/arm64 +release: + name: Template + profile: template + previous_tag_command: latest-release-tag +upstream: + name: AIO Template + version_key: UPSTREAM_VERSION + digest_arg: UPSTREAM_IMAGE_DIGEST +template: + xml_paths: + - template-aio.xml + generated: false +catalog: + published: false + assets: + - source: template-aio.xml + target: template-aio.xml +tests: + unit: tests/template + integration: tests/integration -m integration + checkout_submodules: false diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 8b15bef..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,3 +0,0 @@ -github: - - JSONbored -ko_fi: jsonbored diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index a147b4f..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Bug report -description: Report a problem with the AIO container, XML, or docs -title: "[Bug]: " -labels: - - bug -body: - - type: textarea - id: summary - attributes: - label: Summary - description: What is broken? - validations: - required: true - - type: textarea - id: steps - attributes: - label: Steps to reproduce - validations: - required: true - - type: textarea - id: expected - attributes: - label: Expected behavior - validations: - required: true - - type: textarea - id: environment - attributes: - label: Environment - description: Include Unraid version, image tag, and relevant settings diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 7fd9603..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Security reports - url: https://github.com/JSONbored/unraid-aio-template/security/policy - about: Do not open public issues for vulnerabilities. Use GitHub private vulnerability reporting instead. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 41dbdfa..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Feature request -description: Suggest an improvement to the template, AIO image, or Unraid CA experience -title: "[Feature]: " -labels: - - enhancement -body: - - type: textarea - id: problem - attributes: - label: Problem to solve - description: What is missing, confusing, or unnecessarily hard today? - validations: - required: true - - type: textarea - id: proposed - attributes: - label: Proposed improvement - description: Describe the change you want and why it would help. - validations: - required: true - - type: textarea - id: context - attributes: - label: Additional context - description: Include app-specific, Unraid-specific, or user-experience details that matter. diff --git a/.github/ISSUE_TEMPLATE/installation_help.yml b/.github/ISSUE_TEMPLATE/installation_help.yml deleted file mode 100644 index 89bbdaa..0000000 --- a/.github/ISSUE_TEMPLATE/installation_help.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Installation help -description: Get help with first-run setup, Unraid mapping choices, or upgrade behavior -title: "[Help]: " -labels: - - question -body: - - type: textarea - id: goal - attributes: - label: What are you trying to do? - description: Describe the install, upgrade, or configuration task you are working through. - validations: - required: true - - type: textarea - id: current - attributes: - label: What is happening now? - description: Include the exact symptom, error, or point where you got stuck. - validations: - required: true - - type: textarea - id: environment - attributes: - label: Environment - description: Include Unraid version, image tag, relevant template values, and whether this is a fresh install or an upgrade. - validations: - required: true - - type: textarea - id: logs - attributes: - label: Relevant logs or screenshots - description: Paste container logs or screenshots that will help reproduce the problem faster. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index a9ccaf5..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,16 +0,0 @@ -# Pull Request - -## Summary - -- what changed -- why it changed - -## Validation - -- [ ] local validation suite passed -- [ ] docs updated if behavior changed -- [ ] XML updated if config surface changed - -## Risks - -- note any migration, data, or compatibility risk diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 038d903..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: CI / AIO Template - -on: - push: - branches: [main] - paths: - - .aio-fleet.yml - - .github/** - - .github/workflows/** - - .trunk/** - - AGENTS.md - - CHANGELOG.md - - Dockerfile - - README.md - - SECURITY.md - - SUPPORT.md - - assets/** - - cliff.toml - - components.toml - - components/** - - docs/** - - docs/upstream/** - - pyproject.toml - - renovate.json - - requirements-dev.txt - - rootfs/** - - scripts/** - - template-aio.xml - - tests/** - - upstream.toml - pull_request: - branches: [main] - paths: - - .aio-fleet.yml - - .github/** - - .github/workflows/** - - .trunk/** - - AGENTS.md - - CHANGELOG.md - - Dockerfile - - README.md - - SECURITY.md - - SUPPORT.md - - assets/** - - cliff.toml - - components.toml - - components/** - - docs/** - - docs/upstream/** - - pyproject.toml - - renovate.json - - requirements-dev.txt - - rootfs/** - - scripts/** - - template-aio.xml - - tests/** - - upstream.toml - #checkov:skip=CKV_GHA_7: manual dispatch inputs are constrained maintainer controls. - workflow_dispatch: - inputs: - publish_target: - description: Optional maintainer image publish target - required: false - default: none - type: choice - options: - - none - - aio - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - aio-build: - uses: JSONbored/aio-fleet/.github/workflows/aio-build.yml@a13769d03bb1eade5323709ea4d873874fb78cb2 - permissions: - contents: read - packages: write - pull-requests: write - with: - app_slug: unraid-aio-template - image_name: jsonbored/unraid-aio-template - workflow_title: CI / AIO Template - docker_cache_scope: unraid-aio-template-image - pytest_image_tag: aio-template:pytest - publish_profile: template - upstream_name: AIO Template - image_description: Reusable Unraid AIO template image - python_version: "3.13" - trunk_org_slug: aethereal - publish_platforms: linux/amd64,linux/arm64 - checkout_submodules: false - integration_pytest_args: tests/integration -m integration - run_extended_integration: false - extended_integration_pytest_args: "" - manual_publish_target: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_target || 'none' }} - generator_check_command: "" - upstream_digest_arg: UPSTREAM_IMAGE_DIGEST - catalog_published: false - xml_paths: | - template-aio.xml - extra_publish_paths: | - - catalog_assets: | - template-aio.xml|template-aio.xml - secrets: inherit diff --git a/.github/workflows/check-upstream.yml b/.github/workflows/check-upstream.yml deleted file mode 100644 index 3dc4cde..0000000 --- a/.github/workflows/check-upstream.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Check Upstream Version - -on: - schedule: - - cron: 23 7 * * 1 - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - check-upstream: - uses: JSONbored/aio-fleet/.github/workflows/aio-check-upstream.yml@a13769d03bb1eade5323709ea4d873874fb78cb2 - permissions: - contents: write - pull-requests: write - issues: write - with: - workflow_title: Check Upstream Version - component_matrix: '[""]' - commit_paths: | - Dockerfile - secrets: inherit diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml deleted file mode 100644 index dc6b285..0000000 --- a/.github/workflows/publish-release.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Publish Release / Template - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - publish-release: - uses: JSONbored/aio-fleet/.github/workflows/aio-publish-release.yml@a13769d03bb1eade5323709ea4d873874fb78cb2 - permissions: - actions: read - contents: write - with: - release_name: Template - component: "" - workflow_selector: build.yml - secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 8c424a8..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Prepare Release / Template - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - prepare-release: - uses: JSONbored/aio-fleet/.github/workflows/aio-prepare-release.yml@a13769d03bb1eade5323709ea4d873874fb78cb2 - permissions: - contents: write - pull-requests: write - with: - release_name: Template - component: "" - component_label: "" - previous_tag_command: latest-release-tag - secrets: inherit diff --git a/.trunk/.gitignore b/.trunk/.gitignore deleted file mode 100644 index 05e3953..0000000 --- a/.trunk/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -out -logs -actions -notifications -tools -plugins -user_trunk.yaml -user.yaml -tmp diff --git a/.trunk/configs/.hadolint.yaml b/.trunk/configs/.hadolint.yaml deleted file mode 100644 index 98bf0cd..0000000 --- a/.trunk/configs/.hadolint.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# Following source doesn't work in most setups -ignored: - - SC1090 - - SC1091 diff --git a/.trunk/configs/.isort.cfg b/.trunk/configs/.isort.cfg deleted file mode 100644 index b9fb3f3..0000000 --- a/.trunk/configs/.isort.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[settings] -profile=black diff --git a/.trunk/configs/.markdownlint.yaml b/.trunk/configs/.markdownlint.yaml deleted file mode 100644 index 0e23341..0000000 --- a/.trunk/configs/.markdownlint.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# Prettier friendly markdownlint config (all formatting rules disabled) -extends: markdownlint/style/prettier -MD024: - siblings_only: true diff --git a/.trunk/configs/.shellcheckrc b/.trunk/configs/.shellcheckrc deleted file mode 100644 index 8cc03cd..0000000 --- a/.trunk/configs/.shellcheckrc +++ /dev/null @@ -1,6 +0,0 @@ -enable=all -source-path=SCRIPTDIR - -# If you're having issues with shellcheck following source, disable the errors via: -# disable=SC1090 -# disable=SC1091 diff --git a/.trunk/configs/.yamllint.yaml b/.trunk/configs/.yamllint.yaml deleted file mode 100644 index 184e251..0000000 --- a/.trunk/configs/.yamllint.yaml +++ /dev/null @@ -1,7 +0,0 @@ -rules: - quoted-strings: - required: only-when-needed - extra-allowed: ["{|}"] - key-duplicates: {} - octal-values: - forbid-implicit-octal: true diff --git a/.trunk/configs/ruff.toml b/.trunk/configs/ruff.toml deleted file mode 100644 index f5a235c..0000000 --- a/.trunk/configs/ruff.toml +++ /dev/null @@ -1,5 +0,0 @@ -# Generic, formatter-friendly config. -select = ["B", "D3", "E", "F"] - -# Never enforce `E501` (line length violations). This should be handled by formatters. -ignore = ["E501"] diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml deleted file mode 100644 index 460719c..0000000 --- a/.trunk/trunk.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# This file controls the behavior of Trunk: https://docs.trunk.io/cli -# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml -version: 0.1 -cli: - version: 1.25.0 -# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) -plugins: - sources: - - id: trunk - ref: v1.7.6 - uri: https://github.com/trunk-io/plugins - - id: unraid-aio - ref: v0.1.0 - uri: https://github.com/JSONbored/unraid-aio-trunk-config diff --git a/README.md b/README.md index 7a141ed..94d7a6f 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,8 @@ This template is opinionated on purpose. It is built for repos that should be: - starter [`Dockerfile`](/tmp/unraid-aio-template/Dockerfile) for wrapping an upstream image with `s6-overlay` - starter CA XML at [`template-aio.xml`](/tmp/unraid-aio-template/template-aio.xml) - shared pytest harness under [`tests/`](/tmp/unraid-aio-template/tests) -- generic XML validator at [`scripts/validate-template.py`](/tmp/unraid-aio-template/scripts/validate-template.py) -- optional suite component manifest support via [`components.toml`](/tmp/unraid-aio-template/docs/suite-components.md) -- changelog-to-XML sync helper at [`scripts/update-template-changes.py`](/tmp/unraid-aio-template/scripts/update-template-changes.py) -- derived-repo guardrail script at [`scripts/validate-derived-repo.sh`](/tmp/unraid-aio-template/scripts/validate-derived-repo.sh) -- upstream monitor scaffold at [`upstream.toml`](/tmp/unraid-aio-template/upstream.toml) -- GitHub Actions for validation, pytest-backed integration gating, main-branch publish, security checks, and optional `awesome-unraid` sync +- declarative fleet manifest at [`.aio-fleet.yml`](/tmp/unraid-aio-template/.aio-fleet.yml) +- app-owned Docker/rootfs/XML/docs/tests only; shared validation, release, registry, catalog, upstream, and Trunk behavior lives in `aio-fleet` - starter docs, changelog, funding, issue templates, and security policy - public repo checklists under [`docs/`](/tmp/unraid-aio-template/docs) @@ -29,10 +25,8 @@ This template is opinionated on purpose. It is built for repos that should be: - safe defaults for beginners, advanced knobs for power users - generated first-run secrets only when the app truly needs them - no publish until placeholders are gone and pytest passes -- pinned workflow action SHAs and Renovate-managed dependency updates -- stable-only upstream tracking with PR-first updates -- optional upstream image digest tracking for repos that pin immutable manifests -- update automation opens PRs, but merge decisions stay manual +- shared CI, Trunk, upstream tracking, changelog, release, and registry rules are declared in `aio-fleet` +- update automation opens PRs/checks, but merge decisions stay manual - public repos stay public-facing and product-facing only ## Recommended Workflow @@ -43,10 +37,9 @@ This template is opinionated on purpose. It is built for repos that should be: 4. Replace [`assets/app-icon.png`](/tmp/unraid-aio-template/assets/app-icon.png) with the real icon. 5. Follow [`docs/customization-guide.md`](/tmp/unraid-aio-template/docs/customization-guide.md). 6. Follow [`docs/repo-settings.md`](/tmp/unraid-aio-template/docs/repo-settings.md). -7. Once secrets are configured, let `main` pushes handle package publishing and downstream XML sync PRs automatically. -8. Install the Renovate GitHub App for the derived repo so pinned actions and Docker dependencies stay current. -9. Configure [`upstream.toml`](/tmp/unraid-aio-template/upstream.toml) so the repo can monitor the wrapped upstream app. -10. Keep the XML `` block in the fleet-standard date-first format: `### YYYY-MM-DD` followed by short bullet lines only. +7. Add the repo to `aio-fleet/fleet.yml`, then export the app manifest with `python -m aio_fleet export-app-manifest --repo --write`. +8. Let `aio-fleet` own package publishing, downstream XML sync PRs, upstream monitoring, release preparation, and Trunk. +9. Keep the XML `` block in the fleet-standard date-first format generated from `aio-fleet`. For ecosystems that need companion images such as agents, workers, or proxies, use the optional suite component pattern in @@ -54,32 +47,17 @@ use the optional suite component pattern in Most repos should still stay single-component unless the companion is tightly bound to the same upstream product and support surface. -## Actions Variables +## Control Plane -No Actions variables are required for the default JSONbored workflow. +Derived repos should not carry shared workflow, Trunk, release, upstream, or validator shims. `aio-fleet` owns those surfaces and reads the app repo through `.aio-fleet.yml` plus the central `fleet.yml`. -Optional overrides: +The final app repo surface should stay narrow: -- `IMAGE_NAME_OVERRIDE=jsonbored/yourapp-aio` -- `TEMPLATE_XML=yourapp-aio.xml` -- `AWESOME_UNRAID_REPOSITORY=JSONbored/awesome-unraid` -- `AWESOME_UNRAID_XML_NAME=yourapp-aio.xml` -- `AWESOME_UNRAID_ICON_NAME=yourapp.png` -- `TEMPLATE_ICON_PATH=assets/app-icon.png` - -If you do not set the optional sync overrides, the workflow defaults to: - -- target repo: `JSONbored/awesome-unraid` -- XML name: `.xml` -- icon path: `assets/app-icon.png` -- icon name: derived from the XML name, for example `yourapp-aio.xml -> yourapp.png` - -## Required Actions Secret - -- `SYNC_TOKEN` - - fine-grained PAT - - repository access: `JSONbored/awesome-unraid` - - permission: `Contents: Read and write` +- Dockerfile/rootfs/runtime logic +- XML or XML generator +- app-specific assets and docs +- app-specific tests +- `.aio-fleet.yml` ## Files To Customize First @@ -87,15 +65,12 @@ If you do not set the optional sync overrides, the workflow defaults to: - [`template-aio.xml`](/tmp/unraid-aio-template/template-aio.xml) - [`pyproject.toml`](/tmp/unraid-aio-template/pyproject.toml) - [`tests/`](/tmp/unraid-aio-template/tests/) -- [`scripts/validate-template.py`](/tmp/unraid-aio-template/scripts/validate-template.py) -- [`scripts/update-template-changes.py`](/tmp/unraid-aio-template/scripts/update-template-changes.py) -- [`scripts/components.py`](/tmp/unraid-aio-template/scripts/components.py) - [`rootfs/etc/cont-init.d/01-bootstrap.sh`](/tmp/unraid-aio-template/rootfs/etc/cont-init.d/01-bootstrap.sh) - [`rootfs/etc/services.d/app/run`](/tmp/unraid-aio-template/rootfs/etc/services.d/app/run) - [`README.md`](/tmp/unraid-aio-template/README.md) - [`.github/FUNDING.yml`](/tmp/unraid-aio-template/.github/FUNDING.yml) - [`SECURITY.md`](/tmp/unraid-aio-template/SECURITY.md) -- [`upstream.toml`](/tmp/unraid-aio-template/upstream.toml) +- [`.aio-fleet.yml`](/tmp/unraid-aio-template/.aio-fleet.yml) ## Validation Flow @@ -105,19 +80,15 @@ Derived repos created from this template should follow this order: 2. `python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements-dev.txt` 3. `pytest tests/unit tests/template` 4. `pytest tests/integration -m integration` -5. `pytest tests/unit tests/template --junit-xml=reports/pytest-unit.xml -o junit_family=xunit1` -6. `pytest tests/integration -m integration --junit-xml=reports/pytest-integration.xml -o junit_family=xunit1` -7. `./trunk-analytics-cli validate --junit-paths "reports/pytest-unit.xml,reports/pytest-integration.xml"` -8. enable automation -9. CI validation and publish -10. `awesome-unraid` sync using the repo-name-derived defaults or your optional overrides -11. real Unraid install validation - -CI cost model for derived repos: - -- run unit/template tests on relevant PRs and `main` pushes -- run Docker-backed integration tests on build-relevant `main` pushes, on release-metadata `main` pushes that are still publish-eligible, and on manual workflow dispatches -- require integration success before publish jobs can push images +5. from `aio-fleet`: `python -m aio_fleet validate --repo ` +6. from `aio-fleet`: `python -m aio_fleet control-check --repo --sha --event pull_request` +7. real Unraid install validation + +Control-plane cost model for derived repos: + +- run central validation and app-local unit/template tests for pull requests +- run Docker-backed integration tests on `main`, release, or manual control-plane checks +- require `aio-fleet / required` before protected-branch merges - keep local integration runs explicit instead of binding them to every pre-commit or pre-push hook by default Use [`docs/release-checklist.md`](/tmp/unraid-aio-template/docs/release-checklist.md) before making a derived repo public or submitting it to CA. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 04152b4..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,26 +0,0 @@ -# Security Policy - -## Supported Versions - -Only the `main` branch, the current `latest` image tag, and the current semver template release tags are supported with security fixes. - -| Version | Supported | -| ------------------------------------ | --------- | -| main | yes | -| latest | yes | -| current semver template release tags | yes | -| older | no | - -## Reporting a Vulnerability - -Do not open public issues for suspected vulnerabilities. - -- Preferred: GitHub private vulnerability report for the affected repository -- Fallback: email `security@aethereal.dev` - -Include: - -- affected repo, branch, or image tag -- reproduction steps -- impact assessment -- any confirmed mitigation diff --git a/cliff.toml b/cliff.toml deleted file mode 100644 index e6f0791..0000000 --- a/cliff.toml +++ /dev/null @@ -1,44 +0,0 @@ -[changelog] -header = """ -# Changelog - -All notable changes to this project will be documented in this file. -""" -body = """ -{% if version %}## {{ version }} - {{ timestamp | date(format="%Y-%m-%d") }}{% else %}## Unreleased{% endif %} -{% for group, commits in commits | group_by(attribute="group") -%} -### {{ group }} -{% for commit in commits -%} -- {{ commit.message | split(pat="\n") | first | trim | upper_first }} -{% endfor %} -{% if not loop.last %}\n{% endif -%} -{% endfor -%} -""" -trim = true -footer = "" - -[git] -conventional_commits = true -filter_unconventional = false -require_conventional = false -split_commits = false -protect_breaking_commits = true -tag_pattern = '^v?[0-9]+\\.[0-9]+\\.[0-9]+$' -sort_commits = "oldest" -commit_preprocessors = [{ pattern = " \\(#\\d+\\)$", replace = "" }] -commit_parsers = [ - { message = "^Merge pull request", skip = true }, - { message = "^chore\\(release\\):", skip = true }, - { message = "^feat", group = "Features" }, - { message = "^fix", group = "Fixes" }, - { message = "^perf", group = "Performance" }, - { message = "^refactor", group = "Refactors" }, - { message = "^docs?", group = "Documentation" }, - { message = "^ci", group = "CI" }, - { message = "^test", group = "Tests" }, - { message = "^build", group = "Build" }, - { message = "^chore\\(deps", group = "Dependency Updates" }, - { message = "^chore", group = "Maintenance" }, - { message = "^revert", group = "Reverts" }, - { message = "^[A-Z].*", group = "Other Changes" }, -] diff --git a/docs/customization-guide.md b/docs/customization-guide.md index 3729d51..16ff719 100644 --- a/docs/customization-guide.md +++ b/docs/customization-guide.md @@ -10,8 +10,8 @@ Use this when turning the template into a real app repo. 4. Update [`README.md`](/tmp/unraid-aio-template/README.md), [`SECURITY.md`](/tmp/unraid-aio-template/SECURITY.md), and [`.github/FUNDING.yml`](/tmp/unraid-aio-template/.github/FUNDING.yml). 5. Replace the starter service command in [`rootfs/etc/services.d/app/run`](/tmp/unraid-aio-template/rootfs/etc/services.d/app/run). 6. Replace the starter pytest integration assertions in [`tests/integration/test_container_runtime.py`](/tmp/unraid-aio-template/tests/integration/test_container_runtime.py) with the real app lifecycle expectations. -7. Configure [`upstream.toml`](/tmp/unraid-aio-template/upstream.toml) and pin the upstream version in the Dockerfile. -8. Keep the XML `` block in the date-first fleet format: `### YYYY-MM-DD` followed by short bullet lines only, then let [`scripts/update-template-changes.py`](/tmp/unraid-aio-template/scripts/update-template-changes.py) keep it synced from `CHANGELOG.md`. +7. Configure the repo in `aio-fleet/fleet.yml`, export `.aio-fleet.yml`, and pin the upstream version in the Dockerfile. +8. Keep the XML `` block in the date-first fleet format generated by `aio-fleet` release preparation. ## Files You Will Almost Always Touch @@ -20,11 +20,11 @@ Use this when turning the template into a real app repo. - [`README.md`](/tmp/unraid-aio-template/README.md) - [`pyproject.toml`](/tmp/unraid-aio-template/pyproject.toml) - [`tests/integration/test_container_runtime.py`](/tmp/unraid-aio-template/tests/integration/test_container_runtime.py) -- [`scripts/validate-template.py`](/tmp/unraid-aio-template/scripts/validate-template.py) -- [`scripts/update-template-changes.py`](/tmp/unraid-aio-template/scripts/update-template-changes.py) +- central template validation in `aio-fleet` +- central XML `` sync in `aio-fleet` - [`rootfs/etc/cont-init.d/01-bootstrap.sh`](/tmp/unraid-aio-template/rootfs/etc/cont-init.d/01-bootstrap.sh) - [`rootfs/etc/services.d/app/run`](/tmp/unraid-aio-template/rootfs/etc/services.d/app/run) -- [`upstream.toml`](/tmp/unraid-aio-template/upstream.toml) +- [`.aio-fleet.yml`](/tmp/unraid-aio-template/.aio-fleet.yml) ## Internal PostgreSQL @@ -43,7 +43,7 @@ If the derived app does need internal PostgreSQL: ## CI and Publishing -The build workflow publishes from `main` once the required registry and sync secrets are configured. +The central `aio-fleet` control plane publishes from `main` once the required registry and GitHub App secrets are configured. Before enabling it: diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 8d34712..6bd632c 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -8,7 +8,7 @@ - confirm README, SECURITY, and FUNDING are accurate - confirm `Support`, `Project`, `TemplateURL`, and `Icon` URLs are correct - pin the upstream version explicitly -- configure `upstream.toml` +- configure the repo in `aio-fleet/fleet.yml` and export `.aio-fleet.yml` - add a screenshot or demo visual if the app has a UI - set the repo About description, topics, and social preview image - run `pytest tests/unit tests/template` @@ -17,10 +17,10 @@ ## Before Enabling Actions - add optional sync override variables only if you need to diverge from the repo-name defaults -- add `SYNC_TOKEN` -- confirm Renovate is installed for the repo +- confirm the `aio-fleet` GitHub App is installed on this repo and `awesome-unraid` +- confirm shared dependency/upstream automation is represented in `aio-fleet` - verify branch protection and secret scanning are enabled -- confirm `validate-template`, `unit-tests`, and `integration-tests` pass before allowing publish +- confirm `aio-fleet / required` passes before allowing publish ## Before Unraid Submission diff --git a/docs/releases.md b/docs/releases.md index d075c6e..69bb163 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -16,9 +16,8 @@ A template release is a versioned milestone for the scaffolding itself, includin ## Release flow -1. Trigger **Prepare Release / Template** from `main`. -2. The workflow computes the next semver version and opens a release PR that updates `CHANGELOG.md`. -3. The same preparation flow also syncs the template XML `` block from the latest `CHANGELOG.md` entry. -4. Review and merge that PR into `main`. -5. Trigger **Publish Release / Template** from `main`. -6. The workflow reads the merged `CHANGELOG.md` entry, creates the Git tag, and publishes the GitHub Release. +1. From `aio-fleet`, run `python -m aio_fleet release status --repo unraid-aio-template` to inspect the next semver release. +2. Run `python -m aio_fleet release prepare --repo unraid-aio-template` on a release branch, then open a `chore(release): ` PR. +3. Review and merge that PR into `main`. +4. Run the central `aio-fleet` control check for the release target commit and require `aio-fleet / required` to pass. +5. Run `python -m aio_fleet release publish --repo unraid-aio-template` from `aio-fleet` to create the GitHub Release. diff --git a/docs/repo-settings.md b/docs/repo-settings.md index be41f6a..f4962ca 100644 --- a/docs/repo-settings.md +++ b/docs/repo-settings.md @@ -26,11 +26,7 @@ Create a ruleset for `main`: Suggested required checks: -- `validate-template` -- `pinned-actions` -- `dependency-review` -- `unit-tests` -- `integration-tests` +- `aio-fleet / required` ## Actions @@ -40,7 +36,7 @@ Suggested required checks: - Allow GitHub-authored actions and verified creators - Keep default `GITHUB_TOKEN` permissions minimal and only elevate inside jobs that publish - Keep manual dispatch enabled so you can re-run validation or a controlled publish without making a noop commit -- Keep scheduled workflows enabled so upstream monitoring can run automatically +- Keep the central `aio-fleet` scheduled workflow enabled so upstream monitoring can run automatically ## Security @@ -49,7 +45,7 @@ Suggested required checks: - Enable push protection - Enable private vulnerability reporting - Enable code scanning later if you add a relevant analyzer -- Use Renovate for update PRs instead of Dependabot update PRs +- Keep shared dependency and upstream policy in `aio-fleet` ## Packages @@ -58,34 +54,13 @@ Suggested required checks: ## Secrets and Variables -No Actions variables are required by default. - -Optional variables: - -- `IMAGE_NAME_OVERRIDE` -- `TEMPLATE_XML` -- `AWESOME_UNRAID_REPOSITORY` -- `AWESOME_UNRAID_XML_NAME` -- `AWESOME_UNRAID_ICON_NAME` -- `TEMPLATE_ICON_PATH` - -Required secret: - -- `SYNC_TOKEN` - -Default sync behavior without overrides: - -- XML source: `.xml` -- awesome-unraid target repo: `JSONbored/awesome-unraid` -- target XML name: `.xml` -- target icon path source: `assets/app-icon.png` -- target icon name: derived from the XML name, for example `yourapp-aio.xml -> yourapp.png` +App repos should not carry repo-local workflow secrets for shared automation. Configure the GitHub App, Docker Hub credentials, and GHCR token in `aio-fleet`; keep app-local secrets only when the runtime itself needs them. ## Maintenance -- install the Renovate GitHub App on each derived repo -- let Renovate manage pinned GitHub Action SHAs and Docker dependency updates -- review Renovate PRs manually before merging +- keep shared dependency and upstream policy in `aio-fleet` +- let `aio-fleet` own shared workflow, Trunk, and upstream automation +- review generated automation PRs manually before merging ## Derived Repo Checks Before Enabling Automation @@ -96,4 +71,4 @@ Default sync behavior without overrides: - README no longer contains placeholder language - XML points at the correct repo, icon, and support URLs - `pytest tests/unit tests/template` passes locally, including the placeholder and XML checks -- `upstream.toml` matches the real upstream app and update strategy +- `.aio-fleet.yml` matches the central `aio-fleet` manifest and upstream strategy diff --git a/docs/suite-components.md b/docs/suite-components.md index e43265d..42d9264 100644 --- a/docs/suite-components.md +++ b/docs/suite-components.md @@ -1,71 +1,27 @@ # Suite Components -Most AIO repos should stay simple: one repo, one image, one Unraid template. -Some upstream ecosystems need tightly related companion images such as agents, -workers, proxies, or collectors. Use the optional suite component pattern only -when those components share the same upstream product, support surface, catalog -identity, and release ownership. +Suite/component metadata is declared in `aio-fleet/fleet.yml` and exported into the app repo `.aio-fleet.yml`. App repos should not carry `components.toml` or component helper scripts. -## When To Use This Pattern +Use this pattern only when one product genuinely needs multiple published images or XML templates under the same support surface, such as `signoz-aio` plus `signoz-agent`. -Use a suite repo when: +## Component Fields -- the companion is only useful with the primary AIO app -- the repo should share issues, docs, icon assets, and support expectations -- separate Docker images and CA templates are still required -- maintaining another GitHub repository would add overhead without improving the - user experience +Declare component-specific fields in `aio-fleet/fleet.yml`: -Create a separate repo instead when the app is independently useful, has a -different support audience, or would force unrelated releases into the same -history. +- `image_name` +- `docker_cache_scope` +- `pytest_image_tag` +- `context` +- `dockerfile` +- `xml_paths` +- `integration_pytest_args` +- `upstream_version_key` +- `release_suffix` -## `components.toml` +Then export the manifest: -Add `components.toml` at the repo root: - -```toml -[components.example-aio] -type = "aio" -context = "." -dockerfile = "Dockerfile" -template = "example-aio.xml" -image = "jsonbored/example-aio" -dockerhub_image = "jsonbored/example-aio" -cache_scope = "example-aio-image" -upstream_config = "upstream.toml" -release_suffix = "aio" -test_paths = ["tests/unit", "tests/template", "tests/integration"] -sync_paths = ["example-aio.xml", "assets/app-icon.png"] - -[components.example-agent] -type = "agent" -context = "components/example-agent" -dockerfile = "components/example-agent/Dockerfile" -template = "example-agent.xml" -image = "jsonbored/example-agent" -dockerhub_image = "jsonbored/example-agent" -cache_scope = "example-agent-image" -upstream_config = "components/example-agent/upstream.toml" -release_suffix = "agent" -test_paths = ["tests/unit", "tests/template", "tests/integration_agent"] -sync_paths = ["example-agent.xml", "assets/app-icon.png"] +```sh +python -m aio_fleet export-app-manifest --repo --write ``` -Without `components.toml`, scripts fall back to the traditional single-component -repo behavior. - -## Release And Security Expectations - -- Each component publishes to its own image repository so `latest` remains - unambiguous. -- Component image publish jobs must require a build-impacting change for that - component. XML, icon, README, and catalog-only changes should validate and - sync catalog assets without publishing unchanged images. -- App components should keep upstream-aligned release suffixes like `aio`. -- Companion components should use an explicit suffix like `agent`, `worker`, or - `proxy`. -- Agents and workers that use host mounts, Docker sockets, or log directories - must keep those mounts blank by default and document the security tradeoff in - the XML `Description` and `Requires` text. -- Source XML must validate before any catalog sync to `awesome-unraid`. +The app repo keeps only `.aio-fleet.yml` plus the actual component Dockerfile/XML/runtime files. diff --git a/docs/upstream-tracking.md b/docs/upstream-tracking.md index 96e7818..9c057dd 100644 --- a/docs/upstream-tracking.md +++ b/docs/upstream-tracking.md @@ -1,92 +1,23 @@ # Upstream Tracking -Every derived AIO repo should declare the upstream app it wraps and how updates should be handled. +Upstream tracking is owned by `aio-fleet`, not by app-local scripts. Derived repos declare upstream metadata in `.aio-fleet.yml`; the central `aio-fleet/fleet.yml` remains the source for generated manifests and control-plane policy. -## Why This Exists +## Required Inputs -Without upstream monitoring, each AIO repo becomes a manual memory problem. The goal is simple: +- upstream name and source repository +- Dockerfile ARG that pins the upstream version +- optional digest ARGs for images that should be immutable +- update strategy: `pr` for safe single-image bumps, `notify` for multi-image stacks that need manual review -- detect new stable upstream versions -- open a controlled PR or issue -- let the normal repo CI validate the update before it ships +## Dify-Style Multi-Image Stacks -## Files +Dify pins multiple companion images. Keep those bumps explicit so API, web, sandbox, plugin daemon, and digest changes move together in one reviewed release task. -- [`upstream.toml`](/tmp/unraid-aio-template/upstream.toml) -- [`scripts/check-upstream.py`](/tmp/unraid-aio-template/scripts/check-upstream.py) -- [`.github/workflows/check-upstream.yml`](/tmp/unraid-aio-template/.github/workflows/check-upstream.yml) +## Validation -## Recommended Default +Run this from `aio-fleet` after changing upstream metadata or Dockerfile pins: -Use stable-only monitoring with `strategy = "pr"`. - -That means the repo: - -- checks upstream on a schedule -- opens a PR when a new stable version appears -- runs the normal validation and pytest flow -- leaves the final merge decision to you - -## Supported Upstream Types - -- `github-tag` -- `github-release` -- `ghcr-container-tag` - -## Optional Digest Pinning - -When the wrapped upstream publishes immutable image manifests, you can track both the human version and the exact image digest. This is the right fit for repos that pin `FROM upstream-image:@sha256:` and want upstream-monitor PRs to catch digest-only refreshes too. - -Example: - -```toml -[upstream] -name = "Infisical" -type = "github-release" -repo = "Infisical/infisical" -image = "infisical/infisical" -version_source = "dockerfile-arg" -version_key = "UPSTREAM_VERSION" -digest_source = "dockerhub-manifest" -digest_key = "UPSTREAM_IMAGE_DIGEST" -strategy = "pr" -stable_only = true - -[notifications] -release_notes_url = "https://github.com/Infisical/infisical/releases" +```sh +python -m aio_fleet validate --repo +python -m aio_fleet release status --repo ``` - -## Example - -```toml -[upstream] -name = "Sure" -type = "github-tag" -repo = "we-promise/sure" -version_source = "dockerfile-arg" -version_key = "UPSTREAM_VERSION" -strategy = "pr" -stable_only = true - -[notifications] -release_notes_url = "https://github.com/we-promise/sure/releases" -``` - -## Version Pinning Pattern - -Pin the wrapped upstream version explicitly in the Dockerfile: - -```dockerfile -ARG UPSTREAM_VERSION=v0.6.8 -FROM ghcr.io/we-promise/sure:${UPSTREAM_VERSION} -``` - -This gives the upstream monitor a concrete value to compare and update. - -## Stable First - -The default template policy is stable only. Do not expose prerelease channels until the derived repo has: - -- strong integration tests -- confidence in upgrade safety -- a clear reason to offer beta or RC tags publicly diff --git a/renovate.json b/renovate.json deleted file mode 100644 index 310fbe4..0000000 --- a/renovate.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended", - "helpers:pinGitHubActionDigests", - ":dependencyDashboard" - ], - "pinDigests": true, - "labels": ["dependencies"], - "packageRules": [ - { - "matchManagers": ["github-actions", "dockerfile"], - "groupName": "non-major infrastructure updates", - "matchUpdateTypes": ["minor", "patch", "digest"], - "matchCurrentVersion": "!/^0/" - }, - { - "matchManagers": ["github-actions"], - "addLabels": ["github-actions"] - }, - { - "matchManagers": ["dockerfile"], - "addLabels": ["docker"] - } - ] -} diff --git a/scripts/check-upstream.py b/scripts/check-upstream.py deleted file mode 100644 index d2d6593..0000000 --- a/scripts/check-upstream.py +++ /dev/null @@ -1,392 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import configparser -import json -import os -import pathlib -import re -import sys -import urllib.error -import urllib.request -from typing import NoReturn - -ROOT = pathlib.Path(".") -UPSTREAM_FILE = ROOT / "upstream.toml" -DOCKERFILE = ROOT / "Dockerfile" -SEMVER_RE = re.compile( - r"^v?(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" - r"(?:-(?P[0-9A-Za-z.-]+))?$" -) - - -def fail(message: str) -> NoReturn: - print(message, file=sys.stderr) - raise SystemExit(1) - - -def http_json(url: str, headers: dict[str, str] | None = None) -> object: - request = urllib.request.Request( - url, - headers={ - "Accept": "application/vnd.github+json, application/json", - "User-Agent": "jsonbored-unraid-aio-template", - **(headers or {}), - }, - ) - try: - with urllib.request.urlopen(request, timeout=30) as response: # nosec B310 - return json.load(response) - except urllib.error.HTTPError as exc: - fail(f"HTTP error while requesting {url}: {exc.code} {exc.reason}") - except urllib.error.URLError as exc: - fail(f"Network error while requesting {url}: {exc.reason}") - - -def parse_version(value: str) -> tuple[int, int, int, bool, str]: - match = SEMVER_RE.match(value) - if not match: - fail(f"Unsupported version format: {value}") - prerelease = match.group("prerelease") - return ( - int(match.group("major")), - int(match.group("minor")), - int(match.group("patch")), - prerelease is not None, - prerelease or "", - ) - - -def prerelease_sort_key(prerelease: str) -> tuple[tuple[int, object], ...]: - parts: list[tuple[int, object]] = [] - for item in prerelease.split("."): - if item.isdigit(): - parts.append((0, int(item))) - else: - parts.append((1, item)) - return tuple(parts) - - -def version_sort_key( - value: str, -) -> tuple[int, int, int, int, tuple[tuple[int, object], ...]]: - major, minor, patch, is_prerelease, prerelease = parse_version(value) - return ( - major, - minor, - patch, - 0 if is_prerelease else 1, - prerelease_sort_key(prerelease), - ) - - -def filter_versions(values: list[str], stable_only: bool) -> list[str]: - semver_values = [value for value in values if SEMVER_RE.match(value)] - if not stable_only: - return semver_values - stable_values: list[str] = [] - for value in semver_values: - _, _, _, is_prerelease, _ = parse_version(value) - if not is_prerelease: - stable_values.append(value) - return stable_values - - -def github_headers() -> dict[str, str]: - token = os.environ.get("GITHUB_TOKEN", "").strip() - if token: - return {"Authorization": f"Bearer {token}"} - return {} - - -def latest_github_tag(repo: str, stable_only: bool) -> str: - data = http_json( - f"https://api.github.com/repos/{repo}/tags?per_page=100", github_headers() - ) - if not isinstance(data, list): - fail(f"Unexpected GitHub tags response for {repo}") - tags = [ - entry["name"] - for entry in data - if isinstance(entry, dict) and isinstance(entry.get("name"), str) - ] - candidates = filter_versions(tags, stable_only) - if not candidates: - fail(f"No matching tags found for upstream repo {repo}") - return sorted(candidates, key=version_sort_key)[-1] - - -def latest_github_release(repo: str, stable_only: bool) -> str: - data = http_json( - f"https://api.github.com/repos/{repo}/releases?per_page=100", github_headers() - ) - if not isinstance(data, list): - fail(f"Unexpected GitHub releases response for {repo}") - releases: list[str] = [] - for entry in data: - if not isinstance(entry, dict): - continue - tag = entry.get("tag_name") - if not isinstance(tag, str) or not SEMVER_RE.match(tag): - continue - prerelease = bool(entry.get("prerelease")) - if stable_only and prerelease: - continue - releases.append(tag) - if not releases: - fail(f"No matching releases found for upstream repo {repo}") - return sorted(releases, key=version_sort_key)[-1] - - -def latest_ghcr_tag(image: str, stable_only: bool) -> str: - token_data = http_json(f"https://ghcr.io/token?scope=repository:{image}:pull") - if not isinstance(token_data, dict) or not token_data.get("token"): - fail(f"Could not get GHCR token for {image}") - token = str(token_data["token"]) - data = http_json( - f"https://ghcr.io/v2/{image}/tags/list", - {"Authorization": f"Bearer {token}"}, - ) - if not isinstance(data, dict): - fail(f"Unexpected GHCR tags response for {image}") - tags = [tag for tag in data.get("tags", []) if isinstance(tag, str)] - candidates = filter_versions(tags, stable_only) - if not candidates: - fail(f"No matching GHCR tags found for {image}") - return sorted(candidates, key=version_sort_key)[-1] - - -def ghcr_digest_for_tag(image: str, tag: str) -> str: - token_data = http_json(f"https://ghcr.io/token?scope=repository:{image}:pull") - if not isinstance(token_data, dict) or not token_data.get("token"): - fail(f"Could not get GHCR token for {image}") - token = str(token_data["token"]) - request = urllib.request.Request( - f"https://ghcr.io/v2/{image}/manifests/{tag}", - method="HEAD", - headers={ - "Accept": ",".join( - [ - "application/vnd.oci.image.index.v1+json", - "application/vnd.oci.image.manifest.v1+json", - "application/vnd.docker.distribution.manifest.list.v2+json", - "application/vnd.docker.distribution.manifest.v2+json", - ] - ), - "Authorization": f"Bearer {token}", - "User-Agent": "jsonbored-unraid-aio-template", - }, - ) - try: - with urllib.request.urlopen(request, timeout=30) as response: # nosec B310 - digest = response.headers.get("docker-content-digest", "").strip() - if digest: - return digest - except urllib.error.HTTPError as exc: - fail( - f"HTTP error while requesting GHCR manifest for {image}:{tag}: " - f"{exc.code} {exc.reason}" - ) - except urllib.error.URLError as exc: - fail( - f"Network error while requesting GHCR manifest for {image}:{tag}: {exc.reason}" - ) - - fail(f"Could not determine digest for GHCR image {image}:{tag}") - - -def dockerhub_digest_for_tag(image: str, tag: str) -> str: - token_url = ( - "https://auth.docker.io/token" - f"?service=registry.docker.io&scope=repository:{image}:pull" - ) - token_data = http_json(token_url) - if not isinstance(token_data, dict) or not token_data.get("token"): - fail(f"Could not get Docker Hub token for {image}") - - request = urllib.request.Request( - f"https://registry-1.docker.io/v2/{image}/manifests/{tag}", - method="HEAD", - headers={ - "Accept": ",".join( - [ - "application/vnd.oci.image.index.v1+json", - "application/vnd.oci.image.manifest.v1+json", - "application/vnd.docker.distribution.manifest.list.v2+json", - "application/vnd.docker.distribution.manifest.v2+json", - ] - ), - "Authorization": f"Bearer {token_data['token']}", - "User-Agent": "jsonbored-unraid-aio-template", - }, - ) - try: - with urllib.request.urlopen(request, timeout=30) as response: # nosec B310 - digest = response.headers.get("docker-content-digest", "").strip() - if digest: - return digest - except urllib.error.HTTPError as exc: - fail( - f"HTTP error while requesting Docker Hub manifest for {image}:{tag}: " - f"{exc.code} {exc.reason}" - ) - except urllib.error.URLError as exc: - fail( - f"Network error while requesting Docker Hub manifest for {image}:{tag}: {exc.reason}" - ) - - fail(f"Could not determine digest for Docker Hub image {image}:{tag}") - - -def read_local_value(arg_name: str) -> str: - pattern = re.compile(rf"^\s*ARG\s+{re.escape(arg_name)}=(.+?)\s*$") - for line in DOCKERFILE.read_text(encoding="utf-8").splitlines(): - match = pattern.match(line) - if match: - return match.group(1) - fail(f"Could not find ARG {arg_name} in Dockerfile") - - -def write_local_value(arg_name: str, new_value: str) -> None: - pattern = re.compile(rf"^(\s*ARG\s+{re.escape(arg_name)}=).+?(\s*)$") - updated_lines: list[str] = [] - changed = False - for line in DOCKERFILE.read_text(encoding="utf-8").splitlines(): - match = pattern.match(line) - if match: - updated_lines.append(f"{match.group(1)}{new_value}{match.group(2)}") - changed = True - else: - updated_lines.append(line) - if not changed: - fail(f"Could not update ARG {arg_name} in Dockerfile") - DOCKERFILE.write_text("\n".join(updated_lines) + "\n", encoding="utf-8") - - -def write_outputs(outputs: dict[str, str]) -> None: - github_output = os.environ.get("GITHUB_OUTPUT") - if github_output: - with open(github_output, "a", encoding="utf-8") as handle: - for key, value in outputs.items(): - handle.write(f"{key}={value}\n") - else: - for key, value in outputs.items(): - print(f"{key}={value}") - - -def parse_upstream_toml(path: pathlib.Path) -> dict[str, dict[str, object]]: - parser = configparser.ConfigParser() - parser.optionxform = str - parser.read_string(path.read_text(encoding="utf-8")) - - result: dict[str, dict[str, object]] = {} - for section in parser.sections(): - values: dict[str, object] = {} - for key, raw_value in parser.items(section): - value = raw_value.strip() - lower = value.lower() - if lower == "true": - values[key] = True - elif lower == "false": - values[key] = False - else: - values[key] = value.strip('"') - result[section] = values - return result - - -def latest_version_for_config(upstream: dict[str, object], stable_only: bool) -> str: - upstream_type = str(upstream.get("type", "")).strip() - if upstream_type == "github-tag": - return latest_github_tag(str(upstream.get("repo", "")).strip(), stable_only) - if upstream_type == "github-release": - return latest_github_release(str(upstream.get("repo", "")).strip(), stable_only) - if upstream_type == "ghcr-container-tag": - return latest_ghcr_tag(str(upstream.get("image", "")).strip(), stable_only) - fail(f"Unsupported upstream type: {upstream_type}") - - -def latest_digest_for_config(upstream: dict[str, object], version: str) -> str: - digest_source = str(upstream.get("digest_source", "")).strip() - if not digest_source: - return "" - - digest_key = str(upstream.get("digest_key", "")).strip() - image = str(upstream.get("image", "")).strip() - if not digest_key: - fail("digest_source is set but digest_key is missing in upstream.toml") - if not image: - fail("digest_source is set but image is missing in upstream.toml") - - digest_tag = str(upstream.get("digest_tag", "")).strip() or version - if digest_source == "ghcr-manifest": - return ghcr_digest_for_tag(image, digest_tag) - if digest_source == "dockerhub-manifest": - return dockerhub_digest_for_tag(image, digest_tag) - fail(f"Unsupported digest_source: {digest_source}") - - -def main() -> None: - if not UPSTREAM_FILE.exists(): - fail("Missing upstream.toml") - - config = parse_upstream_toml(UPSTREAM_FILE) - upstream = config.get("upstream") - notifications = config.get("notifications", {}) - if not isinstance(upstream, dict): - fail("Invalid upstream.toml: missing [upstream]") - - stable_only = bool(upstream.get("stable_only", True)) - version_key = str(upstream.get("version_key", "")).strip() - if not version_key: - fail("Invalid upstream.toml: missing [upstream].version_key") - - current_version = read_local_value(version_key) - latest_version = latest_version_for_config(upstream, stable_only) - - digest_key = str(upstream.get("digest_key", "")).strip() - current_digest = read_local_value(digest_key) if digest_key else "" - latest_digest = latest_digest_for_config(upstream, latest_version) - updates_available = latest_version != current_version or ( - latest_digest != "" and latest_digest != current_digest - ) - - if os.environ.get("WRITE_UPSTREAM_VERSION") == "true" and updates_available: - write_local_value(version_key, latest_version) - if latest_digest and digest_key: - write_local_value(digest_key, latest_digest) - - release_notes = "" - if isinstance(notifications, dict): - release_notes = str(notifications.get("release_notes_url", "")).strip() - if not release_notes and upstream.get("repo"): - release_notes = f"https://github.com/{upstream['repo']}/releases" - - branch_name = f"codex/upstream-{latest_version}" - pr_title = f"chore(deps): bump upstream to {latest_version}" - if ( - latest_version == current_version - and latest_digest - and latest_digest != current_digest - ): - branch_name = f"codex/upstream-{latest_version}-digest-refresh" - pr_title = f"chore(deps): refresh upstream digest for {latest_version}" - - write_outputs( - { - "current_version": current_version, - "latest_version": latest_version, - "current_digest": current_digest, - "latest_digest": latest_digest, - "updates_available": "true" if updates_available else "false", - "strategy": str(upstream.get("strategy", "pr")).strip() or "pr", - "upstream_name": str(upstream.get("name", "")).strip(), - "release_notes_url": release_notes, - "branch_name": branch_name, - "pr_title": pr_title, - } - ) - - -if __name__ == "__main__": - main() diff --git a/scripts/components.py b/scripts/components.py deleted file mode 100644 index 725169e..0000000 --- a/scripts/components.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import pathlib -import sys -import tomllib -from dataclasses import asdict, dataclass - -ROOT = pathlib.Path(__file__).resolve().parents[1] -COMPONENTS_FILE = ROOT / "components.toml" - - -@dataclass(frozen=True) -class Component: - name: str - type: str - context: pathlib.Path - dockerfile: pathlib.Path - template: pathlib.Path - image: str - dockerhub_image: str - cache_scope: str - upstream_config: pathlib.Path - release_suffix: str - test_paths: tuple[pathlib.Path, ...] - sync_paths: tuple[pathlib.Path, ...] - - def to_json(self) -> dict[str, object]: - data = asdict(self) - for key in ("context", "dockerfile", "template", "upstream_config"): - data[key] = str(data[key]) - data["test_paths"] = [str(path) for path in self.test_paths] - data["sync_paths"] = [str(path) for path in self.sync_paths] - return data - - -def root_relative(path_value: str) -> pathlib.Path: - return pathlib.Path(path_value) - - -def default_template_path() -> pathlib.Path: - repo_xml = ROOT / f"{ROOT.name}.xml" - if repo_xml.exists(): - return root_relative(repo_xml.name) - - xml_files = sorted(path for path in ROOT.glob("*.xml") if path.is_file()) - if len(xml_files) == 1: - return root_relative(xml_files[0].name) - - return root_relative("template-aio.xml") - - -def default_component() -> Component: - repo_name = ROOT.name - template = default_template_path() - return Component( - name=repo_name, - type="aio", - context=root_relative("."), - dockerfile=root_relative("Dockerfile"), - template=template, - image=f"jsonbored/{repo_name}", - dockerhub_image=f"jsonbored/{repo_name}", - cache_scope=f"{repo_name}-image", - upstream_config=root_relative("upstream.toml"), - release_suffix="aio", - test_paths=( - root_relative("tests/unit"), - root_relative("tests/template"), - root_relative("tests/integration"), - ), - sync_paths=(template, root_relative("assets/app-icon.png")), - ) - - -def load_components(path: pathlib.Path | None = None) -> list[Component]: - if path is None: - path = COMPONENTS_FILE - if not path.exists(): - return [default_component()] - - data = tomllib.loads(path.read_text()) - raw_components = data.get("components", {}) - if not isinstance(raw_components, dict) or not raw_components: - raise SystemExit( - "components.toml must contain at least one [components.] table" - ) - - components: list[Component] = [] - for name, raw in raw_components.items(): - if not isinstance(raw, dict): - raise SystemExit(f"components.{name} must be a table") - required = ( - "type", - "context", - "dockerfile", - "template", - "image", - "dockerhub_image", - "cache_scope", - "upstream_config", - "release_suffix", - "test_paths", - ) - missing = [key for key in required if key not in raw] - if missing: - raise SystemExit( - f"components.{name} is missing required keys: {', '.join(missing)}" - ) - - test_paths = raw["test_paths"] - if not isinstance(test_paths, list) or not all( - isinstance(item, str) for item in test_paths - ): - raise SystemExit(f"components.{name}.test_paths must be a list of strings") - - raw_sync_paths = raw.get("sync_paths", [raw["template"], "assets/app-icon.png"]) - if not isinstance(raw_sync_paths, list) or not all( - isinstance(item, str) for item in raw_sync_paths - ): - raise SystemExit(f"components.{name}.sync_paths must be a list of strings") - - components.append( - Component( - name=name, - type=str(raw["type"]), - context=root_relative(str(raw["context"])), - dockerfile=root_relative(str(raw["dockerfile"])), - template=root_relative(str(raw["template"])), - image=str(raw["image"]), - dockerhub_image=str(raw["dockerhub_image"]), - cache_scope=str(raw["cache_scope"]), - upstream_config=root_relative(str(raw["upstream_config"])), - release_suffix=str(raw["release_suffix"]), - test_paths=tuple(root_relative(item) for item in test_paths), - sync_paths=tuple(root_relative(item) for item in raw_sync_paths), - ) - ) - - names = [component.name for component in components] - if len(names) != len(set(names)): - raise SystemExit("components.toml contains duplicate component names") - - return components - - -def get_component(name: str) -> Component: - for component in load_components(): - if component.name == name: - return component - raise SystemExit(f"Unknown component: {name}") - - -def component_for_template(template: pathlib.Path) -> Component | None: - normalized = pathlib.Path(template) - for component in load_components(): - if component.template == normalized: - return component - return None - - -def changed_components(paths: list[str]) -> list[Component]: - components = load_components() - if not paths: - return components - - shared_prefixes = ( - ".github/", - ".trunk/", - "scripts/", - "tests/unit/", - "tests/template/", - ) - shared_files = { - "CHANGELOG.md", - "cliff.toml", - "components.toml", - "pyproject.toml", - "requirements-dev.txt", - "renovate.json", - } - selected: set[str] = set() - for path in paths: - if path in shared_files or path.startswith(shared_prefixes): - return components - for component in components: - watched = { - str(component.context).rstrip("/") + "/", - str(component.dockerfile), - str(component.template), - str(component.upstream_config), - } - if path in watched or any( - path.startswith(item) for item in watched if item.endswith("/") - ): - selected.add(component.name) - - return [component for component in components if component.name in selected] - - -def main() -> int: - parser = argparse.ArgumentParser(description="Inspect suite components.") - subparsers = parser.add_subparsers(dest="command", required=True) - subparsers.add_parser("list") - matrix_parser = subparsers.add_parser("matrix") - matrix_parser.add_argument("paths", nargs="*") - get_parser = subparsers.add_parser("get") - get_parser.add_argument("name") - args = parser.parse_args() - - if args.command == "list": - print(json.dumps([component.to_json() for component in load_components()])) - return 0 - if args.command == "matrix": - print( - json.dumps( - { - "include": [ - component.to_json() - for component in changed_components(args.paths) - ] - } - ) - ) - return 0 - if args.command == "get": - print(json.dumps(get_component(args.name).to_json())) - return 0 - - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/release.py b/scripts/release.py deleted file mode 100755 index aa8db95..0000000 --- a/scripts/release.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import os -import sys -from pathlib import Path - - -def _add_local_aio_fleet() -> None: - repo_root = Path(__file__).resolve().parents[1] - configured_path = os.environ.get("AIO_FLEET_PATH") - for candidate in ( - Path(configured_path).expanduser() / "src" if configured_path else None, - repo_root / ".aio-fleet" / "src", - repo_root.parent / "aio-fleet" / "src", - ): - if candidate and candidate.exists(): - sys.path.insert(0, str(candidate)) - return - - -def main() -> int: - _add_local_aio_fleet() - try: - from aio_fleet.release import main as release_main - except ModuleNotFoundError as exc: - raise SystemExit( - "aio_fleet.release is required. Run from the standard workspace with " - "../aio-fleet present, set AIO_FLEET_PATH to a local aio-fleet checkout, " - "or let the reusable aio-fleet workflows check out .aio-fleet before " - "invoking this shim." - ) from exc - - return int( - release_main( - ["--repo-path", str(Path(__file__).resolve().parents[1]), *sys.argv[1:]] - ) - ) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/update-template-changes.py b/scripts/update-template-changes.py deleted file mode 100644 index fd29bfb..0000000 --- a/scripts/update-template-changes.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import html -import pathlib -import re -import sys - -try: - from components import get_component -except ImportError: # pragma: no cover - used when imported as a package module - from scripts.components import get_component - -ROOT = pathlib.Path(__file__).resolve().parents[1] -DEFAULT_CHANGELOG = ROOT / "CHANGELOG.md" -GENERATED_NOTE = ( - "- Generated from CHANGELOG.md during release preparation. Do not edit manually." -) - - -def resolve_template_path() -> pathlib.Path: - repo_xml = ROOT / f"{ROOT.name}.xml" - if repo_xml.exists(): - return repo_xml - - xml_files = sorted(ROOT.glob("*.xml")) - if len(xml_files) == 1: - return xml_files[0] - - return ROOT / "template-aio.xml" - - -def extract_release_notes(version: str, changelog: pathlib.Path) -> str: - heading = re.compile( - rf"^##\s+(?:\[{re.escape(version)}\]\([^)]+\)|{re.escape(version)})(?:\s+-\s+.+)?$" - ) - next_heading = re.compile(r"^##\s+") - - lines = changelog.read_text().splitlines() - start = None - for idx, line in enumerate(lines): - if heading.match(line.strip()): - start = idx + 1 - break - - if start is None: - raise SystemExit(f"Unable to find release section for {version} in {changelog}") - - end = len(lines) - for idx in range(start, len(lines)): - if next_heading.match(lines[idx].strip()): - end = idx - break - - notes = "\n".join(lines[start:end]).strip() - if not notes: - raise SystemExit(f"Release section for {version} in {changelog} is empty") - return notes - - -def release_heading(version: str, changelog: pathlib.Path) -> str: - heading = re.compile( - rf"^##\s+(?:\[{re.escape(version)}\]\([^)]+\)|{re.escape(version)})(?:\s+-\s+(.+))?$" - ) - for line in changelog.read_text().splitlines(): - match = heading.match(line.strip()) - if match: - release_date = (match.group(1) or "").strip() - if release_date: - return f"### {release_date}" - break - return f"### {version}" - - -def build_changes_body( - version: str, - notes: str, - changelog: pathlib.Path, -) -> str: - lines: list[str] = [release_heading(version, changelog), GENERATED_NOTE] - for line in notes.splitlines(): - stripped = line.strip() - if not stripped: - continue - if stripped.startswith(""): - continue - if re.match(r"^\[[^\]]+\]:\s+https?://", stripped): - continue - if stripped.startswith("Full Changelog:"): - continue - if stripped.startswith("## "): - continue - if stripped.startswith("### "): - continue - if stripped.startswith(("- ", "* ")): - lines.append(f"- {stripped[2:].strip()}") - continue - lines.append(f"- {stripped}") - - if len(lines) == 2: - raise SystemExit("Release notes did not produce any bullet lines for ") - - return "\n".join(lines).strip() - - -def encode_for_template(body: str) -> str: - escaped = html.escape(body, quote=False) - return escaped.replace("\n", " \n") - - -def update_template(template_path: pathlib.Path, encoded_changes: str) -> None: - content = template_path.read_text() - pattern = re.compile(r".*?", re.DOTALL) - replacement = f"{encoded_changes}" - updated, count = pattern.subn(replacement, content, count=1) - if count != 1: - raise SystemExit(f"Expected exactly one block in {template_path}") - template_path.write_text(updated) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Update the template XML block from CHANGELOG release notes." - ) - parser.add_argument("version", help="Release version (example: v0.2.0)") - parser.add_argument("--changelog", type=pathlib.Path, default=DEFAULT_CHANGELOG) - parser.add_argument("--template", type=pathlib.Path, default=None) - parser.add_argument( - "--component", - help="Component name from components.toml whose template should be updated.", - ) - args = parser.parse_args() - - template_path = args.template - if template_path is None and args.component: - template_path = ROOT / get_component(args.component).template - if template_path is None: - template_path = resolve_template_path() - notes = extract_release_notes(args.version, args.changelog) - body = build_changes_body(args.version, notes, args.changelog) - update_template(template_path, encode_for_template(body)) - print( - f"Updated in {template_path} from {args.changelog} for {args.version}" - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/validate-derived-repo.sh b/scripts/validate-derived-repo.sh deleted file mode 100755 index fd969cd..0000000 --- a/scripts/validate-derived-repo.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="${1:-.}" -strict_placeholders="${STRICT_PLACEHOLDERS:-false}" - -args=(validate-derived --repo-path "${repo_root}") -if [[ ${strict_placeholders} == "true" ]]; then - args+=(--strict-placeholders) -fi - -if python3 -c "import aio_fleet.cli" >/dev/null 2>&1; then - exec python3 -m aio_fleet.cli "${args[@]}" -fi - -script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -candidate_roots=() -if [[ -n ${AIO_FLEET_PATH-} ]]; then - candidate_roots+=("${AIO_FLEET_PATH}") -fi -candidate_roots+=( - "${script_dir}/../../aio-fleet" - "${script_dir}/../../../aio-fleet" - "../aio-fleet" - "../../aio-fleet" -) - -for candidate in "${candidate_roots[@]}"; do - if [[ -d ${candidate}/src/aio_fleet ]]; then - python_bin="python3" - if [[ -x ${candidate}/.venv/bin/python ]]; then - python_bin="${candidate}/.venv/bin/python" - fi - PYTHONPATH="${candidate}/src${PYTHONPATH:+:${PYTHONPATH}}" exec "${python_bin}" -m aio_fleet.cli "${args[@]}" - fi -done - -cat >&2 <<'EOF' -template validation error: aio-fleet is required for derived repo validation. -Install aio-fleet or set AIO_FLEET_PATH to a local aio-fleet checkout. -EOF -exit 1 diff --git a/scripts/validate-template.py b/scripts/validate-template.py deleted file mode 100644 index 5e5b72a..0000000 --- a/scripts/validate-template.py +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import os -import re -import sys -from pathlib import Path - -from defusedxml import ElementTree as ET - -try: - from components import load_components -except ( - ImportError -): # pragma: no cover - used when imported as scripts.validate_template - from scripts.components import load_components - -ROOT = Path(__file__).resolve().parents[1] - -GENERATED_CHANGELOG_NOTE = ( - "Generated from CHANGELOG.md during release preparation. Do not edit manually." -) -GENERATED_CHANGELOG_BULLET = f"- {GENERATED_CHANGELOG_NOTE}" -CHANGELOG_HEADER_PATTERN = re.compile( - r"^### (?:\d{4}-\d{2}-\d{2}|Replace with release date)$" -) -LEGACY_CHANGELOG_MARKERS = ( - "[b]Latest release[/b]", - "GitHub Releases", - "Full changelog and release notes:", -) - -REQUIRED_TEXT_FIELDS = ( - "Support", - "Project", - "Overview", - "Category", - "TemplateURL", - "Icon", - "Changes", -) - - -def resolve_template_path() -> Path: - explicit = os.environ.get("TEMPLATE_XML", "").strip() - if explicit: - return ROOT / explicit - - repo_xml = ROOT / f"{ROOT.name}.xml" - if repo_xml.exists(): - return repo_xml - - xml_files = sorted(ROOT.glob("*.xml")) - if len(xml_files) == 1: - return xml_files[0] - - return ROOT / "template-aio.xml" - - -def is_placeholder_template(xml_path: Path) -> bool: - return xml_path.name == "template-aio.xml" or ROOT.name == "unraid-aio-template" - - -def fail(message: str) -> int: - print(message, file=sys.stderr) - return 1 - - -def validate_changes_block(xml_path: Path, changes: str) -> int: - for marker in LEGACY_CHANGELOG_MARKERS: - if marker in changes: - return fail( - f"{xml_path.name} still includes the legacy release-link format: {marker}" - ) - - lines = [line.strip() for line in changes.splitlines() if line.strip()] - if len(lines) < 2: - return fail( - f"{xml_path.name} must contain a date heading and bullet lines" - ) - - if not CHANGELOG_HEADER_PATTERN.fullmatch(lines[0]): - return fail( - f"{xml_path.name} must start with '### YYYY-MM-DD' or the template placeholder heading" - ) - - if lines[1] != GENERATED_CHANGELOG_BULLET: - return fail( - f"{xml_path.name} second line should be '{GENERATED_CHANGELOG_BULLET}'" - ) - - invalid_lines = [line for line in lines[1:] if not line.startswith("- ")] - if invalid_lines: - return fail( - f"{xml_path.name} must use bullet lines only after the heading; found {invalid_lines[0]!r}" - ) - - return 0 - - -def validate_template(xml_path: Path) -> int: - if not xml_path.exists(): - return fail(f"Template XML not found: {xml_path}") - - tree = ET.parse(xml_path) - root = tree.getroot() - if root.tag != "Container": - return fail(f"{xml_path.name} root tag should be ") - if root.attrib.get("version") != "2": - return fail(f'{xml_path.name} should declare ') - - for field in REQUIRED_TEXT_FIELDS: - value = (root.findtext(field) or "").strip() - if not value: - return fail(f"{xml_path.name} is missing a non-empty <{field}> field") - - template_url = (root.findtext("TemplateURL") or "").strip() - if "awesome-unraid/main/" not in template_url: - return fail( - f"{xml_path.name} TemplateURL should point at raw awesome-unraid/main XML" - ) - - icon_url = (root.findtext("Icon") or "").strip() - if "awesome-unraid/main/icons/" not in icon_url: - return fail( - f"{xml_path.name} Icon should point at raw awesome-unraid/main/icons asset" - ) - - changes = (root.findtext("Changes") or "").strip() - if GENERATED_CHANGELOG_NOTE not in changes: - return fail( - f"{xml_path.name} should include the generated-from-CHANGELOG note" - ) - changes_status = validate_changes_block(xml_path, changes) - if changes_status: - return changes_status - - invalid_option_configs: list[str] = [] - invalid_pipe_configs: list[str] = [] - for config in root.findall(".//Config"): - name = config.attrib.get("Name", config.attrib.get("Target", "")) - if config.findall("Option"): - invalid_option_configs.append(name) - - default = config.attrib.get("Default", "") - if "|" not in default: - continue - - allowed_values = default.split("|") - if any(value == "" for value in allowed_values): - invalid_pipe_configs.append( - f"{name} (allowed={allowed_values!r}, empty pipe options are not allowed)" - ) - continue - - selected_value = (config.text or "").strip() - if selected_value not in allowed_values: - invalid_pipe_configs.append( - f"{name} (selected={selected_value!r}, allowed={allowed_values!r})" - ) - - if invalid_option_configs: - print( - f"{xml_path.name} uses nested