OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / .github / workflows / umbrel-app-validate.yml

190 lines · yaml ref:
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