name: start-wrt

# CI-UNVALIDATED: the make targets these jobs wrap are proven locally (riscv
# cross-build, image build, sysupgrade OTA), but no job here has been run yet.
# Validate `compile` first, then `image` (build-image=true), then `deploy` (S3
# upload + beta-registry register). See projects/start-wrt/CONTRIBUTING.md.

on:
  workflow_call:
  workflow_dispatch:
    inputs:
      build-image:
        type: boolean
        description: Also build the full OpenWrt image (slow)
        default: false
      deploy:
        type: choice
        description: Deploy (upload images to S3 + register into the beta registry; release cut locally via manage-release.sh)
        options:
          - NONE
          - release
      force-overwrite:
        type: boolean
        description: Replace S3 objects that already exist for this commit
        default: false
  # `paths:` below mirror projects/start-wrt/build.mk's build inputs — keep
  # both in sync (see root AGENTS.md "Coupled changes"). The web is part of the
  # root Angular workspace, so the workspace inputs (shared-libs/ts-modules/**,
  # angular.json, package.json, package-lock.json, tsconfig.json) are listed too.
  push:
    branches:
      - master
      - integration/*
    paths:
      - 'projects/start-wrt/**'
      - 'shared-libs/crates/**'
      - 'shared-libs/ts-modules/**'
      - 'Cargo.toml'
      - 'Cargo.lock'
      - 'angular.json'
      - 'package.json'
      - 'package-lock.json'
      - 'tsconfig.json'
      - 'tsconfig.lib.json'
      - 'build/**'
      - '.github/workflows/start-wrt.yaml'
      - '.github/actions/setup-build/**'
      # Doc-only edits under the allowlisted project trees (e.g.
      # projects/start-wrt/docs/**) shouldn't trigger a build.
      - '!**/*.md'
      # Alpha's registry entry reads the notes from this commit.
      - 'projects/start-wrt/release-notes/**'
  pull_request:
    # The jobs below skip drafts, and `ready_for_review` is not a default
    # activity type, so without it a draft marked ready is never checked.
    types: [opened, synchronize, reopened, ready_for_review]
    branches:
      - master
      - integration/*
    paths:
      - 'projects/start-wrt/**'
      - 'shared-libs/crates/**'
      - 'shared-libs/ts-modules/**'
      - 'Cargo.toml'
      - 'Cargo.lock'
      - 'angular.json'
      - 'package.json'
      - 'package-lock.json'
      - 'tsconfig.json'
      - 'tsconfig.lib.json'
      - 'build/**'
      - '.github/workflows/start-wrt.yaml'
      - '.github/actions/setup-build/**'
      # Doc-only edits under the allowlisted project trees (e.g.
      # projects/start-wrt/docs/**) shouldn't trigger a build.
      - '!**/*.md'

# The group is scoped by event so a master push can't cancel an in-flight
# dispatched release build. Only pull requests cancel: a push to master
# publishes to alpha, so master pushes queue behind each other rather than
# aborting a publish half-done.
concurrency:
  group: start-wrt-${{ github.event_name }}-${{ github.head_ref || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  NODEJS_VERSION: '24.17.0'

jobs:
  # Fast path: cross-compile the riscv64 binary (embeds the web UI). Does NOT
  # need the OpenWrt tree.
  compile:
    name: Build riscv64 binary
    if: github.event.pull_request.draft != true
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-build
        with:
          nodejs-version: ${{ env.NODEJS_VERSION }}

      # build/verify-isa.sh disassembles the binary to reject instructions the
      # SpaceMiT K1 can't run; it runs llvm-objdump inside the same
      # start9/cargo-zigbuild container as the build — no host riscv binutils.
      - name: Make start-wrt
        run: make start-wrt
        env:
          SCCACHE_GHA_ENABLED: on
          SCCACHE_GHA_VERSION: 0

      - uses: actions/upload-artifact@v7
        with:
          name: startwrt-riscv64
          path: target/riscv64gc-unknown-linux-musl/release/startwrt
          retention-days: 14

  # Heavy path: full flashable OpenWrt image. It downloads the pinned upstream
  # OpenWrt tarball (build/openwrt-version, patched from openwrt-patches/ +
  # openwrt-overlay/) and runs a multi-hour build, so it is gated to the three
  # cases that need a real image:
  #
  #   - a dispatch that asks for one, or deploys;
  #   - a non-draft integration/* branch **of this repo** merging into master,
  #     since the batch has to be flashable before it ships. head_ref is only a
  #     branch name and a fork picks its own, so the head repo is checked too —
  #     otherwise anyone could spend hours of CI by naming a fork branch
  #     integration/anything;
  #   - a push to master, once continuous alpha deploys are switched on. That
  #     one is opt-in via the STARTWRT_ALPHA_REGISTRY repo variable: unset,
  #     master pushes stay on the fast `compile` path, since there is no alpha
  #     StartWRT registry to publish to yet.
  image:
    name: Build OpenWrt image
    if: >-
      ${{
        (github.event_name == 'workflow_dispatch' && (github.event.inputs.build-image == 'true' || github.event.inputs.deploy == 'release'))
        || (github.event_name == 'push' && github.ref == 'refs/heads/master' && vars.STARTWRT_ALPHA_REGISTRY != '')
        || (
          github.event_name == 'pull_request'
          && github.event.pull_request.draft != true
          && github.base_ref == 'master'
          && startsWith(github.head_ref, 'integration/')
          && github.event.pull_request.head.repo.full_name == github.repository
        )
      }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup-build
        with:
          nodejs-version: ${{ env.NODEJS_VERSION }}

      # Host deps for the OpenWrt build, restored from the old standalone
      # repo's setup-build action (the monorepo's shared setup-build serves
      # every product, so this install lives here instead).
      - name: Install OpenWrt build dependencies
        run: |
          # The runner image's Microsoft apt sources 403 intermittently, and one failing source fails the whole update.
          grep -rl 'packages\.microsoft\.com' /etc/apt/sources.list.d/ 2>/dev/null | sudo xargs -r rm -f || true
          sudo apt-get update
          sudo apt-get install -y \
            build-essential clang flex bison g++ gawk gcc-multilib \
            g++-multilib gettext git libncurses-dev libssl-dev \
            python3-setuptools rsync swig unzip \
            zlib1g-dev file wget device-tree-compiler

      # Restored BEFORE openwrt-setup.sh runs — the script rebuilds openwrt/
      # around the cached dl/ (which also caches the pinned source tarball).
      - name: Cache OpenWrt download directory
        uses: actions/cache@v4
        with:
          path: projects/start-wrt/openwrt/dl
          # Bust the cache when the upstream pin, feed set, or OpenWrt config
          # changes; restore-keys lets a partial (older) cache seed a fresh run.
          key: openwrt-dl-${{ hashFiles('projects/start-wrt/build/openwrt-version', 'projects/start-wrt/build/feeds.conf', 'projects/start-wrt/build/openwrt.diffconfig') }}
          restore-keys: openwrt-dl-

      - name: Setup OpenWrt (fetch pinned upstream, patch, feeds, config, download)
        run: make start-wrt-openwrt-setup

      - name: Build image
        run: make start-wrt-image

      - uses: actions/upload-artifact@v7
        with:
          name: startwrt-openwrt-image
          # Mirrors build.mk's STARTWRT_BASENAME: the image rule renames the raw
          # OpenWrt outputs to startwrt-<version>-<githash7>_spacemit-k1-*.
          path: results/startwrt-*
          retention-days: 14

  # Publish path: upload the built images to shared S3 (the registry CDN serves
  # them from here) and register + index the version into the beta registry,
  # where beta routers (UCI `startwrt.system.registry` pointed at it) soak it —
  # the same secrets.DEV_KEY publish CI already does for OS builds in
  # startos-iso.yaml. Tagging, cutting the GitHub release, and the beta ->
  # production promotion (`release start-wrt`) stay deliberate local gates run
  # from the top-level scripts/manage-release.sh — the promote re-signs with the
  # local developer key, so production never trusts the CI key.
  # Two ways in: a dispatch with deploy == 'release' (into beta, matching
  # startos-iso), or a push to master, which continuously deploys into the alpha
  # registry named by the STARTWRT_ALPHA_REGISTRY repo variable. With that
  # variable unset there is no alpha channel, so master pushes skip both this and
  # the `image` job it needs.
  deploy:
    name: Deploy
    needs: [image]
    if: >-
      ${{
        !cancelled() && needs.image.result == 'success'
        && (
          (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy == 'release')
          || (github.event_name == 'push' && github.ref == 'refs/heads/master' && vars.STARTWRT_ALPHA_REGISTRY != '')
        )
      }}
    runs-on: ubuntu-latest
    # One lock for every deploy, not one per channel: the workflow-level group is
    # scoped by event, and the channels are not independent anyway — both write
    # the same `v$VERSION/` S3 keys for a given commit.
    concurrency:
      group: startwrt-deploy
      cancel-in-progress: false
    # S3_CDN/PLATFORM (and the compat floor in the register step) mirror
    # scripts/manage-release.sh's STARTWRT_S3_CDN / STARTWRT_PLATFORM /
    # STARTWRT_COMPAT_FLOOR, and the dispatch REGISTRY mirrors
    # STARTWRT_SOURCE_REGISTRY, whose cmd_register is the manual fallback for the
    # register/index steps below — keep them in sync (root AGENTS.md "Coupled
    # changes"). The alpha registry has no manage-release.sh counterpart: nothing
    # is ever released from it.
    env:
      S3_BUCKET: s3://startwrt-images
      S3_CDN: https://startwrt-images.nyc3.cdn.digitaloceanspaces.com
      REGISTRY: ${{ github.event_name == 'push' && vars.STARTWRT_ALPHA_REGISTRY || 'https://startwrt-beta-registry.start9.com' }}
      PLATFORM: spacemit,k1-x
    steps:
      - uses: actions/checkout@v6
        with:
          sparse-checkout: |
            projects/start-wrt/backend/ctrl/Cargo.toml
            projects/start-wrt/release-notes/*
            scripts/manage-release.sh
          sparse-checkout-cone-mode: false

      # Canonical StartWRT version lives in the ctrl crate manifest (mirrors the
      # top CHANGELOG.md entry). The web/package.json the standalone workflow
      # read was removed when the UI folded into the root Angular workspace.
      - name: Determine version
        id: version
        run: |
          VERSION=$(sed -n 's/^version = "\([^"]*\)".*/\1/p' \
            projects/start-wrt/backend/ctrl/Cargo.toml | head -1)
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
          echo "Version: $VERSION"

      - name: Download image artifact
        uses: actions/download-artifact@v8
        with:
          name: startwrt-openwrt-image
          path: artifacts/

      - name: Install start-cli
        run: |
          ARCH=$(uname -m)
          OS=$(uname -s | tr '[:upper:]' '[:lower:]')
          ASSET_NAME="start-cli_${ARCH}-${OS}"
          DOWNLOAD_URL=$(curl -fsS \
            -H "Authorization: token ${{ github.token }}" \
            https://api.github.com/repos/Start9Labs/start-technologies/releases \
            | jq -r '[.[].assets[] | select(.name=="'"$ASSET_NAME"'")] | first | .browser_download_url')
          curl -fsSL \
            -H "Authorization: token ${{ github.token }}" \
            -H "Accept: application/octet-stream" \
            "$DOWNLOAD_URL" -o /tmp/start-cli
          sudo install -m 755 /tmp/start-cli /usr/local/bin/start-cli
          echo "start-cli: $(start-cli --version)"

      - name: Set up developer key
        run: |
          mkdir -p ~/.startos
          printf '%s' "${{ secrets.DEV_KEY }}" > ~/.startos/developer.key.pem

      - name: Configure S3
        run: |
          sudo apt-get install -y -qq s3cmd > /dev/null
          cat > ~/.s3cfg <<EOF
          [default]
          access_key = ${{ secrets.S3_ACCESS_KEY }}
          secret_key = ${{ secrets.S3_SECRET_KEY }}
          host_base = nyc3.digitaloceanspaces.com
          host_bucket = %(bucket)s.nyc3.digitaloceanspaces.com
          use_https = True
          EOF

      # An existing object under one of these keys is a hard stop — basenames are
      # commit-derived, so the key existing means this commit was already
      # published, and promotion copies asset URLs through verbatim, so a
      # beta or production commitment may already point at those exact bytes.
      # Re-dispatch with force-overwrite to replace one deliberately.
      - name: Upload images to S3
        env:
          FORCE_OVERWRITE: ${{ github.event.inputs.force-overwrite == 'true' }}
        run: |
          VERSION="${{ steps.version.outputs.version }}"
          cd artifacts
          conflicts=0
          for file in *.img *.img.gz; do
            [ -f "$file" ] || continue
            key="${{ env.S3_BUCKET }}/v${VERSION}/$file"
            if [ "$FORCE_OVERWRITE" != true ] && [ -n "$(s3cmd ls "$key")" ]; then
              echo "::error::$key already exists — this commit has been published before. Overwriting it would invalidate any beta or production commitment pointing at that URL. Re-dispatch with force-overwrite to replace it deliberately."
              conflicts=$((conflicts + 1))
              continue
            fi
            echo "Uploading $file..."
            s3cmd put -P "$file" "$key"
          done
          [ "$conflicts" -eq 0 ] || exit 1

      # `registry os asset add` rejects a write whose blake3 differs from the one
      # already committed to that platform slot, so re-indexing a version that
      # already holds a build fails. Alpha rolls the same version number on every
      # master push, so drop the version entry first. Beta never republishes in
      # place and is left alone.
      - name: Drop the previous alpha build of this version
        if: github.event_name == 'push'
        run: |
          VERSION="${{ steps.version.outputs.version }}"
          start-cli --registry="${{ env.REGISTRY }}" registry os version remove "$VERSION" \
            || echo "::notice::v${VERSION} was not indexed in ${{ env.REGISTRY }} yet."

      # The compat range is the set of installed versions allowed to upgrade to
      # this one; the floor matches manage-release.sh's STARTWRT_COMPAT_FLOOR.
      - name: Register StartWRT version in the target registry
        run: |
          VERSION="${{ steps.version.outputs.version }}"
          NOTES=$(CHANGELOG_REF="${{ github.sha }}" ./scripts/manage-release.sh notes start-wrt) \
            || { echo "::warning::No release notes for v${VERSION}; registering without them."; NOTES=""; }
          start-cli --registry="${{ env.REGISTRY }}" registry os version add \
            "$VERSION" "v${VERSION}" "$NOTES" ">=0.1.0-beta.1 <=${VERSION}"

      - name: Index assets in the target registry
        run: |
          VERSION="${{ steps.version.outputs.version }}"
          cd artifacts
          for file in *.img *.img.gz; do
            [ -f "$file" ] || continue
            # start-cli infers the asset slot from the file extension and only
            # accepts iso/img/squashfs. Both images ship gzipped, so present
            # each under a hardlink whose extension names its slot: the sdcard
            # image as .img (the fresh-install slot) and the sysupgrade image
            # as .squashfs (start-os's update-asset slot). The indexed URLs
            # still point at the honestly-named .img.gz files on S3 (the
            # registry only requires the URL's bytes to match the signed
            # blake3 commitment, which the hardlinks share).
            case "$file" in
              *-sdcard.img.gz) index_file="${file%.img.gz}.img" ;;
              *-sysupgrade.img.gz) index_file="${file%.img.gz}.squashfs" ;;
              *) index_file="$file" ;;
            esac
            [ "$index_file" = "$file" ] || ln -f "$file" "$index_file"
            echo "Indexing $file for platform ${{ env.PLATFORM }}..."
            start-cli --registry="${{ env.REGISTRY }}" registry os asset add \
              --platform="${{ env.PLATFORM }}" --version="$VERSION" \
              "$index_file" "${{ env.S3_CDN }}/v${VERSION}/$file"
          done