release: prepare v0.25.0-rc.9 repair candidate #65
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Validate Umbrel app package | |
| # Catches breakage in the umbrel-app/ submission directory before it | |
| # reaches a PR against getumbrel/umbrel-apps. | |
| # | |
| # Triggers on any change touching umbrel-app/ — PRs and pushes to main. | |
| on: | |
| push: | |
| branches: [main] | |
| paths: ['umbrel-app/**', '.github/workflows/umbrel-app-validate.yml'] | |
| pull_request: | |
| paths: ['umbrel-app/**', '.github/workflows/umbrel-app-validate.yml'] | |
| workflow_dispatch: | |
| jobs: | |
| validate: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Verify required files exist | |
| run: | | |
| set -euo pipefail | |
| required=( | |
| umbrel-app/umbrel-app.yml | |
| umbrel-app/docker-compose.yml | |
| umbrel-app/README.md | |
| ) | |
| for f in "${required[@]}"; do | |
| if [[ ! -f "$f" ]]; then | |
| echo "::error::Missing required file: $f" | |
| exit 1 | |
| fi | |
| done | |
| echo "All required files present." | |
| - name: Verify umbrel-app.yml is valid YAML and has required keys | |
| run: | | |
| python3 - <<'PY' | |
| import sys | |
| import yaml | |
| with open('umbrel-app/umbrel-app.yml') as f: | |
| data = yaml.safe_load(f) | |
| required = [ | |
| 'manifestVersion', 'id', 'category', 'name', 'version', | |
| 'tagline', 'description', 'developer', 'website', | |
| 'repo', 'support', 'port', 'gallery' | |
| ] | |
| missing = [k for k in required if k not in data] | |
| if missing: | |
| print(f"::error::umbrel-app.yml missing keys: {missing}", file=sys.stderr) | |
| sys.exit(1) | |
| if data['manifestVersion'] not in (1, '1', 1.1, '1.1'): | |
| print(f"::error::manifestVersion must be 1 or 1.1, got {data['manifestVersion']}", file=sys.stderr) | |
| sys.exit(1) | |
| # Category must be one of Umbrel's actual valid values, observed | |
| # by enumerating categories in the live umbrel-apps repo. Submitting | |
| # an unknown category gets the PR rejected immediately. | |
| VALID_CATEGORIES = { | |
| 'ai', 'automation', 'bitcoin', 'crypto', 'developer', | |
| 'files', 'finance', 'media', 'networking', 'social' | |
| } | |
| if data['category'] not in VALID_CATEGORIES: | |
| print(f"::error::category '{data['category']}' is not in the valid set: {sorted(VALID_CATEGORIES)}", file=sys.stderr) | |
| sys.exit(1) | |
| # App ID must be lowercase + dashes only per Umbrel spec. | |
| import re | |
| if not re.match(r'^[a-z][a-z0-9\-]*$', data['id']): | |
| print(f"::error::id '{data['id']}' must be lowercase letters/digits/dashes, starting with a letter", file=sys.stderr) | |
| sys.exit(1) | |
| if not isinstance(data['port'], int) or data['port'] < 1 or data['port'] > 65535: | |
| print(f"::error::port must be 1-65535, got {data['port']}", file=sys.stderr) | |
| sys.exit(1) | |
| submission = data.get('submission', '') | |
| pending_submission = 'https://github.com/getumbrel/umbrel-apps/pull/PENDING' | |
| official_pr_re = r'^https://github\.com/getumbrel/umbrel-apps/pull/[1-9][0-9]*$' | |
| if not isinstance(submission, str) or not submission: | |
| print("::error::submission must be set for the official Umbrel package", file=sys.stderr) | |
| sys.exit(1) | |
| if submission != pending_submission and not re.match(official_pr_re, submission): | |
| print("::error::submission must be a getumbrel/umbrel-apps pull request URL or the PENDING placeholder", file=sys.stderr) | |
| sys.exit(1) | |
| if submission == pending_submission: | |
| print("::notice::submission is still PENDING; replace it with the real getumbrel/umbrel-apps PR URL before reviewer handoff") | |
| if data.get('releaseNotes', '') != '': | |
| print("::error::releaseNotes must stay empty while submission is PENDING for a first official submission", file=sys.stderr) | |
| sys.exit(1) | |
| if not isinstance(data['gallery'], list): | |
| print("::error::gallery must be a list", file=sys.stderr) | |
| sys.exit(1) | |
| # Official first submissions keep gallery empty; Umbrel reviewers | |
| # commit final store-gallery assets separately. If we do list images, | |
| # the list must already be a complete App Store gallery. | |
| if data['gallery'] and len(data['gallery']) < 3: | |
| print(f"::error::populated gallery must list at least 3 images, got {len(data['gallery'])}", file=sys.stderr) | |
| sys.exit(1) | |
| if len(data['gallery']) > 5: | |
| print(f"::error::gallery must list at most 5 images, got {len(data['gallery'])}", file=sys.stderr) | |
| sys.exit(1) | |
| print(f"umbrel-app.yml validates: id={data['id']} version={data['version']}") | |
| PY | |
| - name: Verify docker-compose.yml is valid Compose v3 | |
| run: | | |
| set -euo pipefail | |
| python3 - <<'PY' | |
| import sys | |
| import yaml | |
| with open('umbrel-app/docker-compose.yml') as f: | |
| compose = yaml.safe_load(f) | |
| if 'services' not in compose: | |
| print("::error::docker-compose.yml has no 'services' block", file=sys.stderr) | |
| sys.exit(1) | |
| # Umbrel apps must declare app_proxy and web services | |
| required_services = ['app_proxy', 'web'] | |
| missing = [s for s in required_services if s not in compose['services']] | |
| if missing: | |
| print(f"::error::docker-compose.yml missing services: {missing}", file=sys.stderr) | |
| sys.exit(1) | |
| web = compose['services']['web'] | |
| if 'image' not in web: | |
| print("::error::web service must specify an image", file=sys.stderr) | |
| sys.exit(1) | |
| # Supply-chain gate (audit HR-DIS-002), FAIL CLOSED: the web image | |
| # must be pinned by an immutable sha256 digest, not a floating tag. | |
| # A tag-only ref lets whoever controls the registry/tag swap the | |
| # bytes that run on every install; the digest makes the pull | |
| # content-addressed and verifiable. Shape: name:tag@sha256:<64 hex>. | |
| import re as _re | |
| image_ref = str(web['image']) | |
| digest_pin = _re.compile( | |
| r'^ghcr\.io/bigdestiny2/p2p-hiverelay:' | |
| r'\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?' | |
| r'@sha256:[a-f0-9]{64}$' | |
| ) | |
| if not digest_pin.match(image_ref): | |
| print( | |
| f"::error::web image '{image_ref}' is not digest-pinned. " | |
| "It must be ghcr.io/bigdestiny2/p2p-hiverelay:<version>@sha256:<64 hex> " | |
| "(audit HR-DIS-002). Run `npm run release:prepare -- <version> " | |
| "--image-digest sha256:<digest>` to repin; never ship a tag-only ref.", | |
| file=sys.stderr, | |
| ) | |
| sys.exit(1) | |
| # Spec compliance: persistent volume should mount under APP_DATA_DIR | |
| volumes = web.get('volumes', []) | |
| has_data_volume = any('${APP_DATA_DIR}' in v for v in volumes) | |
| if not has_data_volume: | |
| print("::warning::web service should mount a volume under ${APP_DATA_DIR}", file=sys.stderr) | |
| print(f"docker-compose.yml validates: image={web['image']}") | |
| PY | |
| - name: Verify gallery image names and dimensions when listed | |
| run: | | |
| set -euo pipefail | |
| node scripts/check-umbrel-gallery.mjs | |
| if [[ ! -f umbrel-app/icon.svg ]]; then | |
| echo "::warning::Icon missing: umbrel-app/icon.svg (required before App Store submission)" | |
| else | |
| # Quick sanity check that icon dimensions match Umbrel's 256x256 | |
| # spec. We grep for viewBox/width/height — not foolproof but | |
| # catches the common mistake of submitting an oversized file. | |
| if ! grep -qE 'viewBox="0 0 256 256"|width="256"' umbrel-app/icon.svg; then | |
| echo "::warning::umbrel-app/icon.svg may not be 256x256 as Umbrel requires (viewBox check failed)" | |
| fi | |
| fi | |
| - name: Verify SUBMISSION-CHECKLIST.md exists | |
| run: | | |
| if [[ ! -f umbrel-app/SUBMISSION-CHECKLIST.md ]]; then | |
| echo "::warning::SUBMISSION-CHECKLIST.md missing — recommended for tracking pre-submission work" | |
| fi |