OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / .github / workflows / release-surfaces.yml

1232 lines · yaml ref:
name: Release surfaces

on:
  push:
    branches:
      - ship/core3-release-readiness-2026-06-24
    tags:
      - 'v*'
  release:
    types: [published]
  workflow_dispatch:
    inputs:
      version:
        description: Release tag/version, for example v0.16.4
        required: true
      channel:
        description: Fleet channel to promote
        required: true
        default: both
        type: choice
        options:
          - canary
          - stable
          - both
          - none

permissions:
  contents: write
  packages: write
  # cosign keyless (OIDC) image signing — no long-lived signing key stored
  # anywhere; Fulcio issues a short-lived cert from the job's OIDC token
  # (audit HR-DIS-001).
  id-token: write

concurrency:
  group: release-surfaces
  cancel-in-progress: false

env:
  IMAGE_NAME: ghcr.io/bigdestiny2/p2p-hiverelay

jobs:
  sync:
    runs-on: ubuntu-latest
    env:
      UMBREL_STORE_TOKEN: ${{ secrets.UMBREL_STORE_TOKEN }}
      UMBREL_OFFICIAL_PR_TOKEN: ${{ secrets.UMBREL_OFFICIAL_PR_TOKEN }}
      UMBREL_OFFICIAL_FORK: ${{ secrets.UMBREL_OFFICIAL_FORK }}
      ECOSYSTEM_CONSUMER_TOKEN: ${{ secrets.ECOSYSTEM_CONSUMER_TOKEN }}
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
      STARTOS_DEVELOPER_KEY_PEM: ${{ secrets.STARTOS_DEVELOPER_KEY_PEM }}
      STARTOS_REGISTRY_URL: ${{ secrets.STARTOS_REGISTRY_URL }}
      FLEET_SSH_PRIVATE_KEY: ${{ secrets.FLEET_SSH_PRIVATE_KEY }}
      FLEET_ROLLOUT_TIMEOUT_MS: ${{ vars.FLEET_ROLLOUT_TIMEOUT_MS }}

    steps:
      - name: Checkout HiveRelay
        uses: actions/checkout@v7
        with:
          fetch-depth: 0
          path: hiverelay

      - name: Resolve release inputs
        id: rel
        shell: bash
        env:
          RELEASE_EVENT_NAME: ${{ github.event_name }}
          RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
          RELEASE_IS_PRERELEASE: ${{ github.event.release.prerelease }}
          PUSH_REF_NAME: ${{ github.ref_name }}
          PUSH_SHA: ${{ github.sha }}
          DISPATCH_VERSION: ${{ inputs.version }}
          DISPATCH_CHANNEL: ${{ inputs.channel }}
        run: |
          set -euo pipefail
          is_branch_candidate=false
          if [ "$RELEASE_EVENT_NAME" = "release" ]; then
            version="$RELEASE_TAG_NAME"
            requested_channel="${HIVERELAY_RELEASE_CHANNEL:-}"
            release_prerelease="$RELEASE_IS_PRERELEASE"
          elif [ "$RELEASE_EVENT_NAME" = "push" ] && [ "$PUSH_REF_NAME" = "ship/core3-release-readiness-2026-06-24" ]; then
            base_version="$(node -p "require('./hiverelay/package.json').version")"
            short_sha="${PUSH_SHA:0:12}"
            version="v${base_version}-ship.20260624.${short_sha}"
            requested_channel="none"
            release_prerelease="true"
            is_branch_candidate=true
          elif [ "$RELEASE_EVENT_NAME" = "push" ]; then
            version="$PUSH_REF_NAME"
            requested_channel="${HIVERELAY_RELEASE_CHANNEL:-}"
            release_prerelease="false"
          else
            version="$DISPATCH_VERSION"
            requested_channel="$DISPATCH_CHANNEL"
            release_prerelease="false"
          fi
          if [[ ! "$version" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]]; then
            echo "Invalid release version: $version" >&2
            exit 1
          fi
          semver="${BASH_REMATCH[1]}"
          version="v$semver"
          is_prerelease=false
          if [[ "$semver" == *-* || "$release_prerelease" = "true" ]]; then
            is_prerelease=true
          fi
          if [ -z "$requested_channel" ]; then
            if [ "$is_prerelease" = "true" ]; then
              channel=none
            else
              channel=both
            fi
          else
            channel="$requested_channel"
          fi
          case "$channel" in
            canary|stable|both|none) ;;
            *)
              echo "Invalid release channel: $channel" >&2
              exit 1
              ;;
          esac
          if [ "$is_branch_candidate" = "true" ]; then
            gateway_policy="$(cat <<EOF
          effective_channel=$channel
          public_gateway_enabled=false
          public_gateway_manifest_status=not-applicable
          public_gateway_manifest_path=fleet/public-hive-gateway-release.json
          public_gateway_manifest_sha256=
          public_gateway_release_target=$version
          public_gateway_commit_sha=$PUSH_SHA
          public_gateway_admission_profile=
          public_gateway_cohort_size=0
          EOF
          )"
          else
            gateway_policy="$(node hiverelay/scripts/resolve-public-hive-gateway-release.mjs \
              --repo hiverelay \
              --ref "$version" \
              --release-target "$version" \
              --requested-channel "$channel" \
              --format github)"
          fi
          resolved_channel="$(printf '%s\n' "$gateway_policy" | sed -n 's/^effective_channel=//p')"
          case "$resolved_channel" in
            canary|stable|both|none) channel="$resolved_channel" ;;
            *)
              echo "Public gateway resolver returned an invalid effective channel." >&2
              exit 1
              ;;
          esac
          if [ "$is_prerelease" = "true" ] && [ "$channel" != "none" ]; then
            echo "Pre-release $version cannot promote fleet/app-store channel '$channel'; use channel=none." >&2
            exit 1
          fi
          printf '%s\n' "$gateway_policy" >> "$GITHUB_OUTPUT"
          echo "version=$version" >> "$GITHUB_OUTPUT"
          echo "semver=$semver" >> "$GITHUB_OUTPUT"
          echo "channel=$channel" >> "$GITHUB_OUTPUT"
          echo "is_prerelease=$is_prerelease" >> "$GITHUB_OUTPUT"
          echo "is_branch_candidate=$is_branch_candidate" >> "$GITHUB_OUTPUT"

      - name: Initialize release evidence
        shell: bash
        run: |
          set -euo pipefail
          release_surfaces_status=pending
          release_distribution_status=pending
          npm_publish_status=pending
          startos_release_asset_status=pending
          if [ "${{ steps.rel.outputs.is_prerelease }}" = "true" ]; then
            release_surfaces_status=skipped
            release_distribution_status=skipped
            npm_publish_status=skipped
          fi
          if [ "${{ steps.rel.outputs.is_branch_candidate }}" = "true" ]; then
            startos_release_asset_status=skipped
          fi
          fleet_rollout_status=skipped
          if [ "${{ steps.rel.outputs.public_gateway_enabled }}" = "true" ] && \
             [ "${{ steps.rel.outputs.is_prerelease }}" != "true" ]; then
            fleet_rollout_status=deferred-gateway-canary-gated
          fi
          {
            echo "HIVERELAY_RELEASE_VERSION=${{ steps.rel.outputs.version }}"
            echo "HIVERELAY_RELEASE_SEMVER=${{ steps.rel.outputs.semver }}"
            echo "HIVERELAY_RELEASE_CHANNEL=${{ steps.rel.outputs.channel }}"
            echo "HIVERELAY_RELEASE_PRERELEASE=${{ steps.rel.outputs.is_prerelease }}"
            echo "HIVERELAY_RELEASE_CANDIDATE=${{ steps.rel.outputs.is_branch_candidate }}"
            echo "HIVERELAY_PUBLIC_GATEWAY_RELEASE_ENABLED=${{ steps.rel.outputs.public_gateway_enabled }}"
            echo "HIVERELAY_PUBLIC_GATEWAY_MANIFEST_STATUS=${{ steps.rel.outputs.public_gateway_manifest_status }}"
            echo "HIVERELAY_PUBLIC_GATEWAY_MANIFEST_PATH=${{ steps.rel.outputs.public_gateway_manifest_path }}"
            echo "HIVERELAY_PUBLIC_GATEWAY_MANIFEST_SHA256=${{ steps.rel.outputs.public_gateway_manifest_sha256 }}"
            echo "HIVERELAY_PUBLIC_GATEWAY_RELEASE_TARGET=${{ steps.rel.outputs.public_gateway_release_target }}"
            echo "HIVERELAY_PUBLIC_GATEWAY_COMMIT_SHA=${{ steps.rel.outputs.public_gateway_commit_sha }}"
            echo "HIVERELAY_PUBLIC_GATEWAY_ADMISSION_PROFILE=${{ steps.rel.outputs.public_gateway_admission_profile }}"
            echo "HIVERELAY_PUBLIC_GATEWAY_COHORT_SIZE=${{ steps.rel.outputs.public_gateway_cohort_size }}"
            echo "HIVERELAY_IMAGE_NAME=$IMAGE_NAME"
            echo "HIVERELAY_RELEASE_GATE_STATUS=pending"
            echo "HIVERELAY_RELEASE_DISTRIBUTION_PREFLIGHT_STATUS=$release_distribution_status"
            echo "HIVERELAY_NPM_PUBLISH_STATUS=$npm_publish_status"
            echo "HIVERELAY_RELEASE_IMAGE_SMOKE_STATUS=pending"
            echo "HIVERELAY_RELEASE_IMAGE_SMOKE_EVIDENCE="
            echo "HIVERELAY_RELEASE_IMAGE_SMOKE_EVIDENCE_SHA256="
            echo "HIVERELAY_UMBREL_SMOKE_STATUS=pending"
            echo "HIVERELAY_UMBREL_SMOKE_EVIDENCE="
            echo "HIVERELAY_UMBREL_SMOKE_EVIDENCE_SHA256="
            echo "HIVERELAY_STARTOS_VERIFY_STATUS=pending"
            echo "HIVERELAY_STARTOS_PACKAGE_SHA256="
            echo "HIVERELAY_STARTOS_RELEASE_ASSET_STATUS=$startos_release_asset_status"
            echo "HIVERELAY_RELEASE_SURFACES_STATUS=$release_surfaces_status"
            echo "HIVERELAY_FLEET_ROLLOUT_STATUS=$fleet_rollout_status"
            echo "HIVERELAY_FLEET_ROLLOUT_CHANNEL="
            echo "HIVERELAY_FLEET_ROLLOUT_EVIDENCE="
            echo "HIVERELAY_FLEET_ROLLOUT_EVIDENCE_SHA256="
            echo "HIVERELAY_STARTOS_REGISTRY_STATUS=skipped"
            echo "HIVERELAY_STARTOS_REGISTRY_URL="
            echo "HIVERELAY_STARTOS_REGISTRY_PACKAGE_URL="
            echo "HIVERELAY_STARTOS_REGISTRY_EVIDENCE="
            echo "HIVERELAY_STARTOS_REGISTRY_EVIDENCE_SHA256="
            echo "HIVERELAY_STARTOS_PACKAGE_ID="
            echo "HIVERELAY_UMBREL_OFFICIAL_PR_STATUS=skipped"
            echo "HIVERELAY_UMBREL_OFFICIAL_PR_URL="
            echo "HIVERELAY_UMBREL_OFFICIAL_PR_HEAD="
            echo "HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_SHA="
            echo "HIVERELAY_UMBREL_OFFICIAL_PR_STATE="
            echo "HIVERELAY_UMBREL_OFFICIAL_PR_DRAFT="
            echo "HIVERELAY_UMBREL_OFFICIAL_PR_BASE="
            echo "HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_OWNER="
            echo "HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_REF="
            echo "HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_OID="
            echo "HIVERELAY_UMBREL_COMMUNITY_STORE_VALIDATE_STATUS=skipped"
            echo "HIVERELAY_UMBREL_COMMUNITY_STORE_STATUS=skipped"
            echo "HIVERELAY_UMBREL_COMMUNITY_STORE_COMMIT="
            echo "HIVERELAY_UMBREL_COMMUNITY_STORE_COMMIT_URL="
            echo "HIVERELAY_ECOSYSTEM_CONSUMER_STATUS=$release_surfaces_status"
            echo "HIVERELAY_ECOSYSTEM_CONSUMER_COMMITS="
          } >> "$GITHUB_ENV"

      - name: Verify release ref
        working-directory: hiverelay
        shell: bash
        run: |
          set -euo pipefail
          git fetch --force --tags origin
          git fetch --force origin main
          if [ "${{ steps.rel.outputs.is_branch_candidate }}" = "true" ]; then
            tag_sha="${{ github.sha }}"
            git cat-file -e "$tag_sha^{commit}"
            echo "HIVERELAY_RELEASE_SHA=$tag_sha" >> "$GITHUB_ENV"
            exit 0
          fi
          tag_sha="$(git rev-parse "${{ steps.rel.outputs.version }}^{commit}")"
          if [ "$tag_sha" != "${{ steps.rel.outputs.public_gateway_commit_sha }}" ]; then
            echo "Release tag moved after public gateway policy resolution." >&2
            echo "Resolved: ${{ steps.rel.outputs.public_gateway_commit_sha }}" >&2
            echo "Current:  $tag_sha" >&2
            exit 1
          fi
          main_sha="$(git rev-parse origin/main)"
          if ! git merge-base --is-ancestor "$tag_sha" "$main_sha"; then
            echo "${{ steps.rel.outputs.version }} is not an ancestor of main." >&2
            echo "Main: $main_sha" >&2
            echo "Tag:  $tag_sha" >&2
            exit 1
          fi
          echo "HIVERELAY_RELEASE_SHA=$tag_sha" >> "$GITHUB_ENV"

      # Keep this before any public GitHub Release lookup/create or asset upload.
      # The standalone release-distribution-preflight.yml workflow runs the same
      # checker against masked GitHub Actions values without release side effects.
      - name: Verify stable release distribution credentials
        working-directory: hiverelay
        run: |
          set -euo pipefail
          npm run release:check-distribution-env -- \
            --channel "${{ steps.rel.outputs.channel }}" \
            --prerelease "${{ steps.rel.outputs.is_prerelease }}" \
            --public-gateway-release "${{ steps.rel.outputs.public_gateway_enabled }}" \
            --github-env "$GITHUB_ENV"

      - name: Ensure GitHub Release exists
        if: ${{ github.event_name != 'release' && steps.rel.outputs.is_branch_candidate != 'true' }}
        working-directory: hiverelay
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          version="${{ steps.rel.outputs.version }}"
          if gh release view "$version" >/dev/null 2>&1; then
            echo "GitHub Release $version already exists."
            exit 0
          fi
          notes="Automated HiveRelay release surface sync for $version."
          args=(release create "$version" --verify-tag --title "$version" --notes "$notes")
          if [ "${{ steps.rel.outputs.is_prerelease }}" = "true" ]; then
            args+=(--prerelease)
          fi
          gh "${args[@]}"

      - name: Checkout released source
        working-directory: hiverelay
        run: git checkout --detach "$HIVERELAY_RELEASE_SHA"

      # Community store syncs on EVERY release (including prereleases) whenever
      # the token is present, so it never lags the fleet. The OFFICIAL Umbrel
      # fork PR + npm publish stay non-prerelease-gated below on purpose.
      - name: Checkout Umbrel community store
        id: store_checkout
        if: ${{ env.UMBREL_STORE_TOKEN != '' }}
        continue-on-error: true
        uses: actions/checkout@v7
        with:
          repository: bigdestiny2/blindspark-umbrel-store
          token: ${{ env.UMBREL_STORE_TOKEN }}
          path: blindspark-umbrel-store

      - name: Warn if community store checkout failed
        if: ${{ env.UMBREL_STORE_TOKEN != '' && steps.store_checkout.outcome == 'failure' }}
        run: echo "::warning title=Umbrel community store not synced::UMBREL_STORE_TOKEN is set but the checkout of bigdestiny2/blindspark-umbrel-store failed (bad/expired credentials or missing access). The release + image build continue; fix the token to resume store auto-sync."

      - name: Checkout official Umbrel fork
        if: ${{ env.UMBREL_OFFICIAL_PR_TOKEN != '' && env.UMBREL_OFFICIAL_FORK != '' && steps.rel.outputs.is_prerelease != 'true' }}
        uses: actions/checkout@v7
        with:
          repository: ${{ env.UMBREL_OFFICIAL_FORK }}
          token: ${{ env.UMBREL_OFFICIAL_PR_TOKEN }}
          path: umbrel-official-apps
          fetch-depth: 0

      - name: Checkout PearBrowser ecosystem consumer
        if: ${{ steps.rel.outputs.is_prerelease != 'true' }}
        uses: actions/checkout@v7
        with:
          repository: bigdestiny2/pearbrowser-desktop
          ref: feat/p2p-infra-naming
          token: ${{ env.ECOSYSTEM_CONSUMER_TOKEN }}
          path: 01-browser/pearbrowser-desktop
          fetch-depth: 0

      - name: Checkout PearPaste ecosystem consumer
        if: ${{ steps.rel.outputs.is_prerelease != 'true' }}
        uses: actions/checkout@v7
        with:
          repository: bigdestiny2/pearpaste
          ref: feat/pear-runtime-electron-forge
          token: ${{ env.ECOSYSTEM_CONSUMER_TOKEN }}
          path: 02-apps/pearpaste
          fetch-depth: 0

      - name: Checkout p2pbuilders ecosystem consumer
        if: ${{ steps.rel.outputs.is_prerelease != 'true' }}
        uses: actions/checkout@v7
        with:
          repository: bigdestiny2/p2pbuilders
          ref: main
          token: ${{ env.ECOSYSTEM_CONSUMER_TOKEN }}
          path: 03-sites/pearbrowser-publishers/src/p2pbuilders
          fetch-depth: 0

      - name: Checkout Opengit ecosystem consumer
        if: ${{ steps.rel.outputs.is_prerelease != 'true' }}
        uses: actions/checkout@v7
        with:
          repository: bigdestiny2/Opengit
          ref: main
          token: ${{ env.ECOSYSTEM_CONSUMER_TOKEN }}
          path: 04-experiments/Opengit
          fetch-depth: 0

      - name: Checkout anonGPT ecosystem consumer
        if: ${{ steps.rel.outputs.is_prerelease != 'true' }}
        uses: actions/checkout@v7
        with:
          repository: bigdestiny2/anongpt
          ref: main
          token: ${{ env.ECOSYSTEM_CONSUMER_TOKEN }}
          path: 04-experiments/anongpt-native
          fetch-depth: 0

      - name: Setup Node
        uses: actions/setup-node@v6
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
          cache: npm
          cache-dependency-path: hiverelay/package-lock.json

      - name: Install dependencies
        working-directory: hiverelay
        run: npm ci

      # Workspace/docs ALIGNMENT drift (StartOS/Umbrel/docs version alignment,
      # cross-repo sibling checks) is a release-hygiene signal, NOT code
      # correctness or security. It must WARN loudly but never abort the image
      # build or the community-store sync — otherwise a stale doc version blocks
      # every distribution surface and the community store lags the fleet. Run
      # it in its own non-fatal step so the annotation stays visible.
      - name: Audit workspace alignment (advisory)
        working-directory: hiverelay
        continue-on-error: true
        run: |
          set -euo pipefail
          if ! npm run audit:workspace; then
            echo "::warning title=Workspace alignment drift::npm run audit:workspace failed (non-blocking) — StartOS/Umbrel/docs alignment drift; fix before the next stable release."
            exit 1
          fi

      - name: Audit and test release gate
        working-directory: hiverelay
        run: |
          set -euo pipefail
          # Match the protected CI audit gate: release artifacts contain production
          # dependencies only; the dev-only lint toolchain is exercised below.
          npm audit --omit=dev
          npm run lint
          npm run audit:public-artifacts
          npm run release:check-npm-packages
          node --test test/unit/ecosystem-consumers.test.js
          npm run test:unit
          echo "HIVERELAY_RELEASE_GATE_STATUS=passed" >> "$GITHUB_ENV"

      - name: Verify stable ecosystem app workspace
        if: ${{ steps.rel.outputs.is_prerelease != 'true' }}
        working-directory: hiverelay
        run: |
          set -euo pipefail
          npm run ecosystem:check-workspace -- --required --workspace-root .. --consumer-scope release

      - name: Verify exact source package versions
        if: ${{ steps.rel.outputs.is_branch_candidate == 'true' }}
        working-directory: hiverelay
        env:
          CANDIDATE_SEMVER: ${{ steps.rel.outputs.semver }}
        run: |
          set -euo pipefail
          node - <<'NODE'
          const fs = require('fs')
          const version = process.env.CANDIDATE_SEMVER
          const packageFiles = [
            'package.json',
            'packages/blind-protocol/package.json',
            'packages/blind-ipc/package.json',
            'packages/blind-client/package.json',
            'packages/blind-peercred/package.json',
            'packages/blind-edge/package.json',
            'packages/blind-daemon/package.json',
            'packages/core/package.json',
            'packages/services/package.json',
            'packages/client/package.json',
            'packages/verifier/package.json'
          ]

          for (const file of packageFiles) {
            const json = JSON.parse(fs.readFileSync(file, 'utf8'))
            if (json.version !== version) {
              throw new Error(`${file} is ${json.version}; expected exact source version ${version}`)
            }
          }

          const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'))
          if (lock.version !== version || lock.packages?.['']?.version !== version) {
            throw new Error(`package-lock.json root is not ${version}`)
          }
          NODE
          git diff --exit-code

      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Login to GHCR
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push multi-arch image
        working-directory: hiverelay
        run: |
          set -euo pipefail
          tags=(--tag "$IMAGE_NAME:${{ steps.rel.outputs.semver }}")
          if [ "${{ steps.rel.outputs.is_prerelease }}" != "true" ]; then
            tags+=(--tag "$IMAGE_NAME:latest")
          fi
          docker buildx build \
            --platform linux/amd64,linux/arm64 \
            --build-arg "SOURCE_COMMIT=$HIVERELAY_RELEASE_SHA" \
            --sbom=true \
            --provenance=mode=max \
            "${tags[@]}" \
            --metadata-file /tmp/hiverelay-image.json \
            --push .
          digest="$(node -e "const m=require('/tmp/hiverelay-image.json'); console.log(m['containerimage.digest'])")"
          if ! printf '%s' "$digest" | grep -Eq '^sha256:[a-f0-9]{64}$'; then
            echo "Could not resolve pushed image digest: $digest" >&2
            exit 1
          fi
          echo "HIVERELAY_IMAGE_DIGEST=$digest" >> "$GITHUB_ENV"

      - name: Install cosign
        uses: sigstore/cosign-installer@v3

      # Keyless (OIDC) signature over the pushed multi-arch manifest digest —
      # the SAME digest that gets pinned into docker-compose.yml, the StartOS
      # s9pk, and the release evidence. Signing by digest binds the signature
      # to the exact bytes operators run. Verify with the identity + issuer
      # documented in docs/SUPPLY-CHAIN.md (audit HR-DIS-001).
      - name: Sign release image (cosign keyless)
        working-directory: hiverelay
        env:
          COSIGN_YES: "true"
        run: |
          set -euo pipefail
          cosign sign --recursive "$IMAGE_NAME@$HIVERELAY_IMAGE_DIGEST"
          echo "HIVERELAY_IMAGE_COSIGN_STATUS=signed" >> "$GITHUB_ENV"

      - name: Verify release image manifest platforms
        working-directory: hiverelay
        run: |
          set -euo pipefail
          npm run release:check-image-manifest -- \
            --image "$IMAGE_NAME:${{ steps.rel.outputs.semver }}@$HIVERELAY_IMAGE_DIGEST" \
            --out release-image-manifest-evidence.json
          image_manifest_sha="$(sha256sum release-image-manifest-evidence.json | cut -d ' ' -f 1)"
          if ! [[ "$image_manifest_sha" =~ ^[a-f0-9]{64}$ ]]; then
            echo "Release image manifest evidence SHA-256 is malformed." >&2
            exit 1
          fi
          echo "HIVERELAY_RELEASE_IMAGE_MANIFEST_STATUS=passed" >> "$GITHUB_ENV"
          echo "HIVERELAY_RELEASE_IMAGE_MANIFEST_EVIDENCE=release-image-manifest-evidence.json" >> "$GITHUB_ENV"
          echo "HIVERELAY_RELEASE_IMAGE_MANIFEST_EVIDENCE_SHA256=$image_manifest_sha" >> "$GITHUB_ENV"

      - name: Smoke pushed release image
        working-directory: hiverelay
        run: |
          set -euo pipefail
          npm run release:smoke-image -- \
            "$IMAGE_NAME:${{ steps.rel.outputs.semver }}@$HIVERELAY_IMAGE_DIGEST" \
            --evidence release-image-smoke-evidence.json
          image_smoke_sha="$(sha256sum release-image-smoke-evidence.json | cut -d ' ' -f 1)"
          if ! [[ "$image_smoke_sha" =~ ^[a-f0-9]{64}$ ]]; then
            echo "Release image smoke evidence SHA-256 is malformed." >&2
            exit 1
          fi
          echo "HIVERELAY_RELEASE_IMAGE_SMOKE_EVIDENCE=release-image-smoke-evidence.json" >> "$GITHUB_ENV"
          echo "HIVERELAY_RELEASE_IMAGE_SMOKE_EVIDENCE_SHA256=$image_smoke_sha" >> "$GITHUB_ENV"
          echo "HIVERELAY_RELEASE_IMAGE_SMOKE_STATUS=passed" >> "$GITHUB_ENV"

      - name: Publish npm packages
        if: ${{ steps.rel.outputs.is_prerelease != 'true' }}
        working-directory: hiverelay
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          set -euo pipefail
          expected="${{ steps.rel.outputs.semver }}"
          published=0
          for pkg in packages/core packages/client packages/verifier packages/services; do
            name="$(node -p "require('./${pkg}/package.json').name")"
            version="$(node -p "require('./${pkg}/package.json').version")"
            if [ "$version" != "$expected" ]; then
              echo "$name package.json version is $version; expected $expected" >&2
              exit 1
            fi
            if npm view "$name@$version" version >/dev/null 2>&1; then
              echo "$name@$version already exists on npm; leaving tarball immutable."
            else
              npm publish "$pkg" --access public --tag latest
              published=1
            fi
            latest="$(npm view "$name" dist-tags.latest 2>/dev/null || true)"
            if [ "$latest" != "$version" ]; then
              npm dist-tag add "$name@$version" latest
            fi
            latest="$(npm view "$name" dist-tags.latest)"
            if [ "$latest" != "$version" ]; then
              echo "$name latest dist-tag is $latest; expected $version" >&2
              exit 1
            fi
          done
          npm run release:check-npm-latest -- --expected-version "$expected"
          if [ "$published" = "1" ]; then
            echo "HIVERELAY_NPM_PUBLISH_STATUS=published" >> "$GITHUB_ENV"
          else
            echo "HIVERELAY_NPM_PUBLISH_STATUS=current" >> "$GITHUB_ENV"
          fi

      - name: Sync release metadata
        working-directory: hiverelay
        run: |
          set -euo pipefail
          args=()
          # The community Umbrel store must sync on EVERY release (including
          # prereleases) whenever the checkout is present, so the store never
          # lags the fleet. Only skip the store sync when the token/checkout is
          # absent. Ecosystem-consumer + main-surface sync stay prerelease-gated
          # inside prepare-release.mjs.
          if [ ! -d ../blindspark-umbrel-store ]; then
            args+=(--no-umbrel-store)
          elif [ "${{ steps.rel.outputs.is_prerelease }}" = "true" ]; then
            args+=(--umbrel-store ../blindspark-umbrel-store)
          fi
          npm run release:prepare -- "${{ steps.rel.outputs.version }}" \
            --channel "${{ steps.rel.outputs.channel }}" \
            --image-digest "$HIVERELAY_IMAGE_DIGEST" \
            --ecosystem-workspace-root .. \
            --ecosystem-consumer-scope release \
            --ecosystem-dependency-mode npm-latest \
            "${args[@]}"

      - name: Commit ecosystem app consumer defaults
        if: ${{ steps.rel.outputs.is_prerelease != 'true' }}
        working-directory: hiverelay
        run: |
          set -euo pipefail
          npm run ecosystem:commit-consumers -- \
            --workspace-root .. \
            --version "${{ steps.rel.outputs.version }}" \
            --consumer-scope release \
            --github-env "$GITHUB_ENV"

      - name: Check community appliance packages
        working-directory: hiverelay
        run: npm run community-appliances:check

      - name: Smoke Umbrel package
        working-directory: hiverelay
        run: |
          set -euo pipefail
          npm run umbrel:smoke-package -- \
            --image-ref "$IMAGE_NAME:${{ steps.rel.outputs.semver }}@$HIVERELAY_IMAGE_DIGEST" \
            --evidence umbrel-package-smoke-evidence.json
          umbrel_smoke_sha="$(sha256sum umbrel-package-smoke-evidence.json | cut -d ' ' -f 1)"
          if ! [[ "$umbrel_smoke_sha" =~ ^[a-f0-9]{64}$ ]]; then
            echo "Umbrel package smoke evidence SHA-256 is malformed." >&2
            exit 1
          fi
          echo "HIVERELAY_UMBREL_SMOKE_EVIDENCE=umbrel-package-smoke-evidence.json" >> "$GITHUB_ENV"
          echo "HIVERELAY_UMBREL_SMOKE_EVIDENCE_SHA256=$umbrel_smoke_sha" >> "$GITHUB_ENV"
          echo "HIVERELAY_UMBREL_SMOKE_STATUS=passed" >> "$GITHUB_ENV"

      - name: Install StartOS packaging tools
        working-directory: hiverelay
        run: |
          set -euo pipefail
          sudo apt-get update
          sudo apt-get install -y librsvg2-bin
          git clone --depth 1 --branch v0.3.5.1 --recurse-submodules \
            https://github.com/Start9Labs/start-os.git /tmp/start-os
          cd /tmp/start-os
          mkdir -p web/dist/static
          touch web/dist/static/.keep
          git rev-parse HEAD > GIT_HASH.txt
          rustup toolchain install 1.78.0
          cd core/startos
          cargo +1.78.0 install --path . --bin startbox \
            --no-default-features --features cli,sdk --locked
          ln -sf "$HOME/.cargo/bin/startbox" "$HOME/.cargo/bin/start-sdk"
          echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
          start-sdk --version

      - name: Configure StartOS developer key
        working-directory: hiverelay
        run: |
          set -euo pipefail
          mkdir -p "$HOME/.embassy"
          if [ -n "${STARTOS_DEVELOPER_KEY_PEM:-}" ]; then
            printf '%s\n' "$STARTOS_DEVELOPER_KEY_PEM" > "$HOME/.embassy/developer.key.pem"
            echo "Using StartOS developer key from STARTOS_DEVELOPER_KEY_PEM."
          else
            start-sdk init
            echo "::warning::STARTOS_DEVELOPER_KEY_PEM is not configured; generated an ephemeral key for the sideloadable GitHub Release artifact."
          fi
          chmod 600 "$HOME/.embassy/developer.key.pem"

      - name: Build and verify StartOS package
        working-directory: hiverelay/startos
        run: |
          set -euo pipefail
          make verify IMAGE_DIGEST="$HIVERELAY_IMAGE_DIGEST"
          s9pk_sha="$(sha256sum blindspark.s9pk | cut -d ' ' -f 1)"
          package_id="$(sed -n 's/^id:[[:space:]]*//p' manifest.yaml | head -n 1)"
          if ! [[ "$s9pk_sha" =~ ^[a-f0-9]{64}$ ]]; then
            echo "StartOS package SHA-256 is malformed." >&2
            exit 1
          fi
          if [ -z "$package_id" ]; then
            echo "Could not resolve StartOS package id from startos/manifest.yaml" >&2
            exit 1
          fi
          if ! [[ "$package_id" =~ ^[a-z0-9][a-z0-9-]{1,63}$ ]]; then
            echo "StartOS package id from startos/manifest.yaml is malformed." >&2
            exit 1
          fi
          echo "HIVERELAY_STARTOS_PACKAGE_SHA256=$s9pk_sha" >> "$GITHUB_ENV"
          if [ "${{ steps.rel.outputs.is_prerelease }}" != "true" ]; then
            echo "HIVERELAY_STARTOS_PACKAGE_ID=$package_id" >> "$GITHUB_ENV"
          fi
          echo "HIVERELAY_STARTOS_VERIFY_STATUS=passed" >> "$GITHUB_ENV"

      - name: Upload StartOS package to GitHub Release
        if: ${{ steps.rel.outputs.is_branch_candidate != 'true' }}
        working-directory: hiverelay
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          gh release upload "${{ steps.rel.outputs.version }}" startos/blindspark.s9pk --clobber
          echo "HIVERELAY_STARTOS_RELEASE_ASSET_STATUS=uploaded" >> "$GITHUB_ENV"

      - name: Validate Umbrel community store
        if: ${{ env.UMBREL_STORE_TOKEN != '' && steps.store_checkout.outcome == 'success' }}
        working-directory: blindspark-umbrel-store
        run: |
          set -euo pipefail
          npm run validate
          echo "HIVERELAY_UMBREL_COMMUNITY_STORE_VALIDATE_STATUS=passed" >> "$GITHUB_ENV"

      - name: Commit HiveRelay release surfaces
        if: ${{ steps.rel.outputs.is_prerelease != 'true' }}
        working-directory: hiverelay
        run: |
          set -euo pipefail
          if git diff --quiet; then
            echo "HiveRelay release surfaces already current."
            release_surfaces_sha="$(git rev-parse HEAD)"
            if ! [[ "$release_surfaces_sha" =~ ^[a-f0-9]{40}$ ]]; then
              echo "HiveRelay release surfaces commit SHA is malformed." >&2
              exit 1
            fi
            echo "HIVERELAY_RELEASE_SURFACES_STATUS=current" >> "$GITHUB_ENV"
            echo "HIVERELAY_RELEASE_SURFACES_SHA=$release_surfaces_sha" >> "$GITHUB_ENV"
            exit 0
          fi
          git config user.name "hiverelay-release-bot"
          git config user.email "hiverelay-release-bot@users.noreply.github.com"
          git add package.json package-lock.json README.md \
            packages/core/package.json \
            packages/client/package.json \
            packages/verifier/package.json \
            packages/services/package.json \
            fleet/channels.json \
            umbrel-app/docker-compose.yml umbrel-app/umbrel-app.yml \
            startos/manifest.yaml startos/Makefile startos/README.md \
            truenas-app/app.yaml truenas-app/ix_values.yaml truenas-app/README.md \
            unraid-app/templates/blindspark.xml \
            zimaos-app/Apps/Blindspark/docker-compose.yml \
            runtipi-app/apps/blindspark/config.json \
            runtipi-app/apps/blindspark/docker-compose.yml \
            hexos-app/blindspark.json
          git commit -m "chore: sync release surfaces ${{ steps.rel.outputs.version }}"
          git push origin HEAD:main
          release_surfaces_sha="$(git rev-parse HEAD)"
          if ! [[ "$release_surfaces_sha" =~ ^[a-f0-9]{40}$ ]]; then
            echo "HiveRelay release surfaces commit SHA is malformed." >&2
            exit 1
          fi
          echo "HIVERELAY_RELEASE_SURFACES_STATUS=committed" >> "$GITHUB_ENV"
          echo "HIVERELAY_RELEASE_SURFACES_SHA=$release_surfaces_sha" >> "$GITHUB_ENV"

      - name: Configure fleet rollout SSH key
        if: ${{ env.FLEET_SSH_PRIVATE_KEY != '' && steps.rel.outputs.channel != 'none' && steps.rel.outputs.is_prerelease != 'true' }}
        run: |
          set -euo pipefail
          mkdir -p "$HOME/.ssh"
          printf '%s\n' "$FLEET_SSH_PRIVATE_KEY" > "$HOME/.ssh/hiverelay_fleet"
          chmod 600 "$HOME/.ssh/hiverelay_fleet"

      - name: Verify raw fleet rollout
        if: ${{ env.FLEET_SSH_PRIVATE_KEY != '' && steps.rel.outputs.channel != 'none' && steps.rel.outputs.is_prerelease != 'true' }}
        working-directory: hiverelay
        run: |
          set -euo pipefail
          echo "HIVERELAY_FLEET_ROLLOUT_CHANNEL=${{ steps.rel.outputs.channel }}" >> "$GITHUB_ENV"
          npm run fleet:check-rollout -- \
            --target "${{ steps.rel.outputs.version }}" \
            --channel "${{ steps.rel.outputs.channel }}" \
            --ssh-key "$HOME/.ssh/hiverelay_fleet" \
            --evidence fleet-rollout-evidence.json \
            --timeout-ms "${FLEET_ROLLOUT_TIMEOUT_MS:-1800000}"
          rollout_sha="$(sha256sum fleet-rollout-evidence.json | cut -d ' ' -f 1)"
          if ! [[ "$rollout_sha" =~ ^[a-f0-9]{64}$ ]]; then
            echo "Fleet rollout evidence SHA-256 is malformed." >&2
            exit 1
          fi
          echo "HIVERELAY_FLEET_ROLLOUT_EVIDENCE=fleet-rollout-evidence.json" >> "$GITHUB_ENV"
          echo "HIVERELAY_FLEET_ROLLOUT_EVIDENCE_SHA256=$rollout_sha" >> "$GITHUB_ENV"
          echo "HIVERELAY_FLEET_ROLLOUT_STATUS=verified" >> "$GITHUB_ENV"

      - name: Publish StartOS registry package
        if: ${{ env.STARTOS_REGISTRY_URL != '' && env.STARTOS_DEVELOPER_KEY_PEM != '' && steps.rel.outputs.is_prerelease != 'true' }}
        working-directory: hiverelay
        run: |
          set -euo pipefail
          start-sdk publish "$STARTOS_REGISTRY_URL" startos/blindspark.s9pk
          registry_package_url="${STARTOS_REGISTRY_URL%/}/${HIVERELAY_STARTOS_PACKAGE_ID}"
          export HIVERELAY_STARTOS_REGISTRY_STATUS=published
          export HIVERELAY_STARTOS_REGISTRY_URL="$STARTOS_REGISTRY_URL"
          export HIVERELAY_STARTOS_REGISTRY_PACKAGE_URL="$registry_package_url"
          npm run release:write-startos-registry-evidence -- --out startos-registry-evidence.json
          startos_registry_evidence_sha="$(sha256sum startos-registry-evidence.json | cut -d ' ' -f 1)"
          if ! [[ "$startos_registry_evidence_sha" =~ ^[a-f0-9]{64}$ ]]; then
            echo "StartOS registry evidence SHA-256 is malformed." >&2
            exit 1
          fi
          node scripts/write-github-env.mjs HIVERELAY_STARTOS_REGISTRY_STATUS published
          node scripts/write-github-env.mjs HIVERELAY_STARTOS_REGISTRY_URL "$STARTOS_REGISTRY_URL"
          node scripts/write-github-env.mjs HIVERELAY_STARTOS_REGISTRY_PACKAGE_URL "$registry_package_url"
          node scripts/write-github-env.mjs HIVERELAY_STARTOS_REGISTRY_EVIDENCE startos-registry-evidence.json
          node scripts/write-github-env.mjs HIVERELAY_STARTOS_REGISTRY_EVIDENCE_SHA256 "$startos_registry_evidence_sha"

      - name: Open or update official Umbrel draft PR
        if: ${{ env.UMBREL_OFFICIAL_PR_TOKEN != '' && env.UMBREL_OFFICIAL_FORK != '' && steps.rel.outputs.is_prerelease != 'true' }}
        env:
          GH_TOKEN: ${{ env.UMBREL_OFFICIAL_PR_TOKEN }}
        run: |
          set -euo pipefail
          branch="blindspark-${{ steps.rel.outputs.version }}"
          fork_owner="${UMBREL_OFFICIAL_FORK%%/*}"

          cd umbrel-official-apps
          git remote add upstream https://github.com/getumbrel/umbrel-apps.git 2>/dev/null || true
          git fetch upstream master
          git checkout -B "$branch" upstream/master
          cd ../hiverelay

          npm run umbrel:export-official -- \
            --target ../umbrel-official-apps/blindspark \
            --allow-placeholder

          cd ../umbrel-official-apps
          git config user.name "hiverelay-release-bot"
          git config user.email "hiverelay-release-bot@users.noreply.github.com"
          git add blindspark
          if ! git diff --cached --quiet; then
            git commit -m "Add Blindspark ${{ steps.rel.outputs.version }}"
          fi
          git push --force-with-lease origin "$branch"

          pr_url="$(gh pr list \
            --repo getumbrel/umbrel-apps \
            --head "$fork_owner:$branch" \
            --state open \
            --json url \
            --jq '.[0].url // ""')"
          if [ -z "$pr_url" ]; then
            pr_url="$(gh pr create \
              --repo getumbrel/umbrel-apps \
              --base master \
              --head "$fork_owner:$branch" \
              --draft \
              --title "Add Blindspark — blind relay for the Pear/Holepunch P2P ecosystem" \
              --body "Automated Blindspark official Umbrel package sync for ${{ steps.rel.outputs.version }}. Evidence details will be refreshed by the release workflow before this draft is handed to reviewers.")"
          fi
          if ! [[ "$pr_url" =~ ^https://github\.com/getumbrel/umbrel-apps/pull/[1-9][0-9]*$ ]]; then
            echo "Official Umbrel PR URL from gh must point to getumbrel/umbrel-apps/pull/<number>." >&2
            exit 1
          fi

          cd ../hiverelay
          npm run umbrel:export-official -- \
            --target ../umbrel-official-apps/blindspark \
            --submission-url "$pr_url"
          npm run umbrel:export-official -- \
            --target ../umbrel-official-apps/blindspark \
            --submission-url "$pr_url" \
            --check

          cd ../umbrel-official-apps
          git add blindspark/umbrel-app.yml
          if ! git diff --cached --quiet; then
            git commit -m "Set Blindspark submission URL"
            git push --force-with-lease origin "$branch"
          fi
          official_head_sha="$(git rev-parse HEAD)"
          if ! [[ "$official_head_sha" =~ ^[a-f0-9]{40}$ ]]; then
            echo "Official Umbrel PR head SHA is malformed." >&2
            exit 1
          fi

          pr_state="$(gh pr view "$pr_url" --json state --jq '.state')"
          pr_is_draft="$(gh pr view "$pr_url" --json isDraft --jq '.isDraft')"
          pr_base="$(gh pr view "$pr_url" --json baseRefName --jq '.baseRefName')"
          pr_head_owner="$(gh pr view "$pr_url" --json headRepositoryOwner --jq '.headRepositoryOwner.login')"
          pr_head_ref="$(gh pr view "$pr_url" --json headRefName --jq '.headRefName')"
          pr_head_oid="$(gh pr view "$pr_url" --json headRefOid --jq '.headRefOid')"
          if [ "$pr_state" != "OPEN" ]; then
            echo "Official Umbrel PR must be OPEN before recording release evidence; got '$pr_state'." >&2
            exit 1
          fi
          if [ "$pr_is_draft" != "true" ]; then
            echo "Official Umbrel PR must remain a draft before reviewer handoff; got '$pr_is_draft'." >&2
            exit 1
          fi
          if [ "$pr_base" != "master" ]; then
            echo "Official Umbrel PR base must be master; got '$pr_base'." >&2
            exit 1
          fi
          if [ "$pr_head_owner" != "$fork_owner" ]; then
            echo "Official Umbrel PR head owner must be $fork_owner; got '$pr_head_owner'." >&2
            exit 1
          fi
          if [ "$pr_head_ref" != "$branch" ]; then
            echo "Official Umbrel PR head ref must be $branch; got '$pr_head_ref'." >&2
            exit 1
          fi
          if [ "$pr_head_oid" != "$official_head_sha" ]; then
            echo "Official Umbrel PR head OID must match pushed branch SHA $official_head_sha; got '$pr_head_oid'." >&2
            exit 1
          fi

          cd ../hiverelay
          node scripts/write-github-env.mjs HIVERELAY_UMBREL_OFFICIAL_PR_STATUS draft-pr-ready
          node scripts/write-github-env.mjs HIVERELAY_UMBREL_OFFICIAL_PR_URL "$pr_url"
          node scripts/write-github-env.mjs HIVERELAY_UMBREL_OFFICIAL_PR_HEAD "$fork_owner:$branch"
          node scripts/write-github-env.mjs HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_SHA "$official_head_sha"
          node scripts/write-github-env.mjs HIVERELAY_UMBREL_OFFICIAL_PR_STATE "$pr_state"
          node scripts/write-github-env.mjs HIVERELAY_UMBREL_OFFICIAL_PR_DRAFT "$pr_is_draft"
          node scripts/write-github-env.mjs HIVERELAY_UMBREL_OFFICIAL_PR_BASE "$pr_base"
          node scripts/write-github-env.mjs HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_OWNER "$pr_head_owner"
          node scripts/write-github-env.mjs HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_REF "$pr_head_ref"
          node scripts/write-github-env.mjs HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_OID "$pr_head_oid"
          echo "Official Umbrel draft PR: $pr_url"

      - name: Commit Umbrel community store
        if: ${{ env.UMBREL_STORE_TOKEN != '' && steps.store_checkout.outcome == 'success' }}
        working-directory: blindspark-umbrel-store
        run: |
          set -euo pipefail
          if git diff --quiet; then
            echo "Umbrel community store already current."
            community_sha="$(git rev-parse HEAD)"
            if ! [[ "$community_sha" =~ ^[a-f0-9]{40}$ ]]; then
              echo "Umbrel community-store commit SHA is malformed." >&2
              exit 1
            fi
            echo "HIVERELAY_UMBREL_COMMUNITY_STORE_STATUS=current" >> "$GITHUB_ENV"
            echo "HIVERELAY_UMBREL_COMMUNITY_STORE_COMMIT=$community_sha" >> "$GITHUB_ENV"
            echo "HIVERELAY_UMBREL_COMMUNITY_STORE_COMMIT_URL=https://github.com/bigdestiny2/blindspark-umbrel-store/commit/$community_sha" >> "$GITHUB_ENV"
            exit 0
          fi
          git config user.name "hiverelay-release-bot"
          git config user.email "hiverelay-release-bot@users.noreply.github.com"
          git add package.json README.md index.html \
            hiverelay-blindspark/docker-compose.yml \
            hiverelay-blindspark/umbrel-app.yml
          git commit -m "chore: publish Blindspark ${{ steps.rel.outputs.version }}"
          git push
          community_sha="$(git rev-parse HEAD)"
          if ! [[ "$community_sha" =~ ^[a-f0-9]{40}$ ]]; then
            echo "Umbrel community-store commit SHA is malformed." >&2
            exit 1
          fi
          echo "HIVERELAY_UMBREL_COMMUNITY_STORE_STATUS=pushed" >> "$GITHUB_ENV"
          echo "HIVERELAY_UMBREL_COMMUNITY_STORE_COMMIT=$community_sha" >> "$GITHUB_ENV"
          echo "HIVERELAY_UMBREL_COMMUNITY_STORE_COMMIT_URL=https://github.com/bigdestiny2/blindspark-umbrel-store/commit/$community_sha" >> "$GITHUB_ENV"

      - name: Write release evidence
        if: ${{ always() && !cancelled() && hashFiles('hiverelay/scripts/write-release-evidence.mjs') != '' }}
        working-directory: hiverelay
        env:
          HIVERELAY_WORKFLOW_STATUS: ${{ job.status }}
        run: npm run release:write-evidence -- --out release-evidence.json

      - name: Verify release evidence
        if: ${{ success() && hashFiles('hiverelay/release-evidence.json') != '' }}
        working-directory: hiverelay
        run: |
          set -euo pipefail
          args=(--release release-evidence.json --startos-package startos/blindspark.s9pk)
          if [ -f release-image-manifest-evidence.json ]; then
            args+=(--release-image-manifest release-image-manifest-evidence.json)
          fi
          if [ -f release-image-smoke-evidence.json ]; then
            args+=(--release-image-smoke release-image-smoke-evidence.json)
          fi
          if [ -f umbrel-package-smoke-evidence.json ]; then
            args+=(--umbrel-package-smoke umbrel-package-smoke-evidence.json)
          fi
          if [ -f fleet-rollout-evidence.json ]; then
            args+=(--fleet-rollout fleet-rollout-evidence.json)
          fi
          if [ -f startos-registry-evidence.json ]; then
            args+=(--startos-registry startos-registry-evidence.json)
          fi
          npm run release:verify-evidence -- "${args[@]}"

      - name: Upload release evidence artifact
        if: ${{ always() && !cancelled() && hashFiles('hiverelay/release-evidence.json') != '' }}
        uses: actions/upload-artifact@v7
        with:
          name: release-evidence-${{ steps.rel.outputs.version }}
          path: |
            hiverelay/release-evidence.json
            hiverelay/release-image-manifest-evidence.json
            hiverelay/release-image-smoke-evidence.json
            hiverelay/umbrel-package-smoke-evidence.json
            hiverelay/fleet-rollout-evidence.json
            hiverelay/startos-registry-evidence.json
          retention-days: 90

      - name: Upload release evidence to GitHub Release
        if: ${{ success() && steps.rel.outputs.is_branch_candidate != 'true' }}
        working-directory: hiverelay
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          evidence=(release-evidence.json)
          if [ -f release-image-manifest-evidence.json ]; then
            evidence+=(release-image-manifest-evidence.json)
          fi
          if [ -f release-image-smoke-evidence.json ]; then
            evidence+=(release-image-smoke-evidence.json)
          fi
          if [ -f umbrel-package-smoke-evidence.json ]; then
            evidence+=(umbrel-package-smoke-evidence.json)
          fi
          if [ -f fleet-rollout-evidence.json ]; then
            evidence+=(fleet-rollout-evidence.json)
          fi
          if [ -f startos-registry-evidence.json ]; then
            evidence+=(startos-registry-evidence.json)
          fi
          gh release upload "${{ steps.rel.outputs.version }}" "${evidence[@]}" --clobber

      - name: Verify published release evidence assets
        if: ${{ success() && steps.rel.outputs.is_branch_candidate != 'true' }}
        working-directory: hiverelay
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          bundle_dir="$(mktemp -d)"
          mkdir -p "$bundle_dir/startos"
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'release-evidence.json' \
            --dir "$bundle_dir" \
            --clobber
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'release-image-manifest-evidence.json' \
            --dir "$bundle_dir" \
            --clobber
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'release-image-smoke-evidence.json' \
            --dir "$bundle_dir" \
            --clobber
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'umbrel-package-smoke-evidence.json' \
            --dir "$bundle_dir" \
            --clobber
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'blindspark.s9pk' \
            --dir "$bundle_dir/startos" \
            --clobber
          if [ "${{ steps.rel.outputs.is_prerelease }}" != "true" ] && \
             [ "${{ steps.rel.outputs.public_gateway_enabled }}" != "true" ]; then
            gh release download "${{ steps.rel.outputs.version }}" \
              --pattern 'fleet-rollout-evidence.json' \
              --dir "$bundle_dir" \
              --clobber
          fi
          if [ "${{ steps.rel.outputs.is_prerelease }}" != "true" ]; then
            gh release download "${{ steps.rel.outputs.version }}" \
              --pattern 'startos-registry-evidence.json' \
              --dir "$bundle_dir" \
              --clobber
          fi
          npm run release:verify-evidence -- --bundle-dir "$bundle_dir"

      - name: Upload StartOS registry evidence
        if: ${{ success() && steps.rel.outputs.is_branch_candidate != 'true' && hashFiles('hiverelay/startos-registry-evidence.json') != '' }}
        working-directory: hiverelay
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          gh release upload "${{ steps.rel.outputs.version }}" startos-registry-evidence.json --clobber
          startos_registry_dir="$(mktemp -d)"
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'startos-registry-evidence.json' \
            --dir "$startos_registry_dir" \
            --clobber
          cmp startos-registry-evidence.json "$startos_registry_dir/startos-registry-evidence.json"

      - name: Refresh official Umbrel PR evidence links
        if: ${{ success() && env.UMBREL_OFFICIAL_PR_TOKEN != '' && steps.rel.outputs.is_prerelease != 'true' }}
        working-directory: hiverelay
        env:
          GH_TOKEN: ${{ env.UMBREL_OFFICIAL_PR_TOKEN }}
        run: |
          set -euo pipefail
          if [ -z "${HIVERELAY_UMBREL_OFFICIAL_PR_URL:-}" ]; then
            echo "No official Umbrel PR URL was recorded; skipping PR body refresh."
            exit 0
          fi
          release_base_url="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY}/releases/download/${{ steps.rel.outputs.version }}"
          workflow_url="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
          if [ "${{ steps.rel.outputs.public_gateway_enabled }}" = "true" ]; then
            fleet_evidence_line="- Fleet rollout: deferred to the signed public-gateway canary and observation gate (manifest ${{ steps.rel.outputs.public_gateway_manifest_sha256 }})."
          else
            fleet_evidence_line="- Fleet rollout evidence: ${release_base_url}/fleet-rollout-evidence.json"
          fi
          pr_body="$RUNNER_TEMP/blindspark-official-pr-body.md"
          cat > "$pr_body" <<BODY
          Automated Blindspark official Umbrel package sync for ${{ steps.rel.outputs.version }}.

          Runtime evidence generated by the HiveRelay release workflow:

          - Release evidence: ${release_base_url}/release-evidence.json
          - Release image manifest: ${release_base_url}/release-image-manifest-evidence.json
          - Release image smoke: ${release_base_url}/release-image-smoke-evidence.json
          - Umbrel package smoke: ${release_base_url}/umbrel-package-smoke-evidence.json
          ${fleet_evidence_line}
          - StartOS package: ${release_base_url}/blindspark.s9pk
          - StartOS registry package: ${HIVERELAY_STARTOS_REGISTRY_PACKAGE_URL}
          - StartOS registry evidence: ${release_base_url}/startos-registry-evidence.json
          - Release workflow: ${workflow_url}

          Smoke and release checks cover the Umbrel app_proxy path, setup page, add-wallet write, service catalog/save, restart, stable identity, wallet persistence, service-selection persistence, dashboard WebSocket URL-token rejection, dashboard WebSocket in-band auth, pinned multi-arch image manifests, raw fleet convergence, and StartOS registry publication.

          Manual Umbrel runtime review status: pending real-device review. After the real Umbrel UI pass, generate `umbrel-runtime-review-evidence.json` with `npm run umbrel:write-runtime-review` and verify it with `npm run umbrel:verify-runtime-review`.

          This PR remains a draft until runtime installation through a real Umbrel UI is reviewed and any reviewer asset requests are resolved.
          BODY
          gh pr edit "$HIVERELAY_UMBREL_OFFICIAL_PR_URL" --body-file "$pr_body"
          pr_state="$(gh pr view "$HIVERELAY_UMBREL_OFFICIAL_PR_URL" --json state --jq '.state')"
          pr_is_draft="$(gh pr view "$HIVERELAY_UMBREL_OFFICIAL_PR_URL" --json isDraft --jq '.isDraft')"
          pr_base="$(gh pr view "$HIVERELAY_UMBREL_OFFICIAL_PR_URL" --json baseRefName --jq '.baseRefName')"
          pr_head_owner="$(gh pr view "$HIVERELAY_UMBREL_OFFICIAL_PR_URL" --json headRepositoryOwner --jq '.headRepositoryOwner.login')"
          pr_head_ref="$(gh pr view "$HIVERELAY_UMBREL_OFFICIAL_PR_URL" --json headRefName --jq '.headRefName')"
          pr_head_oid="$(gh pr view "$HIVERELAY_UMBREL_OFFICIAL_PR_URL" --json headRefOid --jq '.headRefOid')"
          if [ "$pr_state" != "OPEN" ]; then
            echo "Official Umbrel PR must still be OPEN before reviewer handoff; got '$pr_state'." >&2
            exit 1
          fi
          if [ "$pr_is_draft" != "true" ]; then
            echo "Official Umbrel PR must still be draft before reviewer handoff; got '$pr_is_draft'." >&2
            exit 1
          fi
          if [ "$pr_base" != "master" ]; then
            echo "Official Umbrel PR base drifted before reviewer handoff; got '$pr_base'." >&2
            exit 1
          fi
          if [ "$pr_head_owner" != "${HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_OWNER:-}" ]; then
            echo "Official Umbrel PR head owner drifted before reviewer handoff; got '$pr_head_owner'." >&2
            exit 1
          fi
          if [ "$pr_head_ref" != "${HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_REF:-}" ]; then
            echo "Official Umbrel PR head ref drifted before reviewer handoff; got '$pr_head_ref'." >&2
            exit 1
          fi
          if [ "$pr_head_oid" != "${HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_OID:-}" ]; then
            echo "Official Umbrel PR head OID drifted before reviewer handoff; got '$pr_head_oid'." >&2
            exit 1
          fi
          export HIVERELAY_UMBREL_OFFICIAL_PR_STATE="$pr_state"
          export HIVERELAY_UMBREL_OFFICIAL_PR_DRAFT="$pr_is_draft"
          export HIVERELAY_UMBREL_OFFICIAL_PR_BASE="$pr_base"
          export HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_OWNER="$pr_head_owner"
          export HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_REF="$pr_head_ref"
          export HIVERELAY_UMBREL_OFFICIAL_PR_HEAD_OID="$pr_head_oid"
          npm run release:write-official-umbrel-pr-evidence -- --out official-umbrel-pr-evidence.json
          official_pr_evidence_sha="$(sha256sum official-umbrel-pr-evidence.json | cut -d ' ' -f 1)"
          if ! [[ "$official_pr_evidence_sha" =~ ^[a-f0-9]{64}$ ]]; then
            echo "Official Umbrel PR evidence SHA-256 is malformed." >&2
            exit 1
          fi
          echo "HIVERELAY_UMBREL_OFFICIAL_PR_BODY_EVIDENCE=official-umbrel-pr-evidence.json" >> "$GITHUB_ENV"
          echo "HIVERELAY_UMBREL_OFFICIAL_PR_BODY_EVIDENCE_SHA256=$official_pr_evidence_sha" >> "$GITHUB_ENV"

      - name: Upload official Umbrel PR evidence
        if: ${{ success() && steps.rel.outputs.is_branch_candidate != 'true' && hashFiles('hiverelay/official-umbrel-pr-evidence.json') != '' }}
        working-directory: hiverelay
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          gh release upload "${{ steps.rel.outputs.version }}" official-umbrel-pr-evidence.json --clobber
          official_pr_dir="$(mktemp -d)"
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'official-umbrel-pr-evidence.json' \
            --dir "$official_pr_dir" \
            --clobber
          cmp official-umbrel-pr-evidence.json "$official_pr_dir/official-umbrel-pr-evidence.json"

      - name: Verify published handoff evidence assets
        if: ${{ success() && steps.rel.outputs.is_prerelease != 'true' }}
        working-directory: hiverelay
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          handoff_dir="$(mktemp -d)"
          mkdir -p "$handoff_dir/startos"
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'release-evidence.json' \
            --dir "$handoff_dir" \
            --clobber
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'release-image-smoke-evidence.json' \
            --dir "$handoff_dir" \
            --clobber
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'release-image-manifest-evidence.json' \
            --dir "$handoff_dir" \
            --clobber
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'umbrel-package-smoke-evidence.json' \
            --dir "$handoff_dir" \
            --clobber
          if [ "${{ steps.rel.outputs.public_gateway_enabled }}" != "true" ]; then
            gh release download "${{ steps.rel.outputs.version }}" \
              --pattern 'fleet-rollout-evidence.json' \
              --dir "$handoff_dir" \
              --clobber
          fi
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'official-umbrel-pr-evidence.json' \
            --dir "$handoff_dir" \
            --clobber
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'startos-registry-evidence.json' \
            --dir "$handoff_dir" \
            --clobber
          gh release download "${{ steps.rel.outputs.version }}" \
            --pattern 'blindspark.s9pk' \
            --dir "$handoff_dir/startos" \
            --clobber
          npm run release:verify-handoff-evidence -- --bundle-dir "$handoff_dir"