name: Release Stable

on:
  push:
    tags:
      - "v[0-9]+.[0-9]+.[0-9]+"   # stable tags only (no -beta suffix)
  workflow_dispatch:
    inputs:
      version:
        description: "Stable version to release (CalVer YYYY.M.D, e.g. 2026.4.21)"
        required: true
        type: string

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

permissions:
  contents: write
  packages: write
  id-token: write # OIDC for cosign keyless + SLSA provenance
  attestations: write # GitHub build attestations

env:
  CARGO_TERM_COLOR: always
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
  RELEASE_CARGO_FEATURES: channel-matrix,channel-lark,whatsapp-web

jobs:
  validate:
    name: Validate Version
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.check.outputs.tag }}
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
      - name: Validate semver and Cargo.toml match
        id: check
        shell: bash
        env:
          INPUT_VERSION: ${{ inputs.version || '' }}
          REF_NAME: ${{ github.ref_name }}
          EVENT_NAME: ${{ github.event_name }}
        run: |
          set -euo pipefail
          cargo_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)

          # Resolve version from tag push or manual input
          if [[ "$EVENT_NAME" == "push" ]]; then
            # Tag push: extract version from tag name (v0.5.9 -> 0.5.9)
            input_version="${REF_NAME#v}"
          else
            input_version="$INPUT_VERSION"
          fi

          if [[ ! "$input_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
            echo "::error::Version must be semver (X.Y.Z). Got: ${input_version}"
            exit 1
          fi

          if [[ "$cargo_version" != "$input_version" ]]; then
            echo "::error::Cargo.toml version (${cargo_version}) does not match input (${input_version}). Bump Cargo.toml first."
            exit 1
          fi

          tag="v${input_version}"

          # Only check tag existence for manual dispatch (tag push means it already exists)
          if [[ "$EVENT_NAME" != "push" ]]; then
            if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
              echo "::error::Tag ${tag} already exists."
              exit 1
            fi
          fi

          echo "tag=${tag}" >> "$GITHUB_OUTPUT"

  web:
    name: Build Web Dashboard
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: web/package-lock.json
      - name: Build web dashboard
        run: cd web && npm ci && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: web-dist
          path: web/dist/
          retention-days: 1

  release-notes:
    name: Generate Release Notes
    runs-on: ubuntu-latest
    outputs:
      notes: ${{ steps.notes.outputs.body }}
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
        with:
          fetch-depth: 0
      - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
        with:
          toolchain: 1.93.0
      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
        with:
          prefix-key: release-notes-cliff
          cache-bin: false
      - name: Build release notes (git-cliff)
        id: notes
        shell: bash
        env:
          INPUT_VERSION: ${{ inputs.version || '' }}
          REF_NAME: ${{ github.ref_name }}
          EVENT_NAME: ${{ github.event_name }}
        run: |
          set -euo pipefail
          if [[ "$EVENT_NAME" == "push" ]]; then
            INPUT_VERSION="${REF_NAME#v}"
          fi
          bash scripts/ci/build_release_notes.sh "" "v${INPUT_VERSION}"

  build:
    name: Build ${{ matrix.target }}
    needs: [validate, web]
    runs-on: ${{ matrix.os }}
    timeout-minutes: 40
    strategy:
      fail-fast: false
      matrix:
        include:
          # Use ubuntu-22.04 for Linux builds to link against glibc 2.35,
          # ensuring compatibility with Ubuntu 22.04+ (#3573).
          - os: ubuntu-22.04
            target: x86_64-unknown-linux-gnu
            artifact: construct
            ext: tar.gz
          - os: ubuntu-22.04
            target: aarch64-unknown-linux-gnu
            artifact: construct
            ext: tar.gz
            cross_compiler: gcc-aarch64-linux-gnu
            linker_env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER
            linker: aarch64-linux-gnu-gcc
          - os: ubuntu-22.04
            target: armv7-unknown-linux-gnueabihf
            artifact: construct
            ext: tar.gz
            cross_compiler: gcc-arm-linux-gnueabihf
            linker_env: CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER
            linker: arm-linux-gnueabihf-gcc
            skip_prometheus: true
          - os: ubuntu-22.04
            target: arm-unknown-linux-gnueabihf
            artifact: construct
            ext: tar.gz
            cross_compiler: gcc-arm-linux-gnueabihf
            linker_env: CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_LINKER
            linker: arm-linux-gnueabihf-gcc
            skip_prometheus: true
          - os: macos-14
            target: aarch64-apple-darwin
            artifact: construct
            ext: tar.gz
          - os: ubuntu-latest
            target: aarch64-linux-android
            artifact: construct
            ext: tar.gz
            ndk: true
            experimental: true
            # wa-rs crates don't compile for Android; exclude whatsapp-web
            cargo_features: "channel-matrix,channel-lark"
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            artifact: construct.exe
            ext: zip
    continue-on-error: ${{ matrix.experimental || false }}
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
      - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
        with:
          toolchain: 1.93.0
          targets: ${{ matrix.target }}
      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
        if: runner.os != 'Windows'
        with:
          prefix-key: ${{ matrix.os }}-${{ matrix.target }}
          cache-bin: false

      - uses: actions/download-artifact@v4
        with:
          name: web-dist
          path: web/dist/

      - name: Install cross compiler
        if: matrix.cross_compiler
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y ${{ matrix.cross_compiler }}

      - name: Setup Android NDK
        if: matrix.ndk
        run: echo "$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" >> "$GITHUB_PATH"

      - name: Build release
        shell: bash
        run: |
          if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
            export "${{ matrix.linker_env }}=${{ matrix.linker }}"
          fi
          # Force ARMv6 codegen for arm-unknown-linux-gnueabihf (#4556)
          # Ubuntu 22.04's gcc-arm-linux-gnueabihf defaults to ARMv7+NEON,
          # which segfaults on ARMv6 devices (e.g. Raspberry Pi Zero W).
          if [ "${{ matrix.target }}" = "arm-unknown-linux-gnueabihf" ]; then
            export CFLAGS_arm_unknown_linux_gnueabihf="-march=armv6 -mfpu=vfp -mfloat-abi=hard"
            export CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_RUSTFLAGS="-C target-feature=-neon"
          fi
          # Use matrix-level feature override if set, otherwise use global RELEASE_CARGO_FEATURES
          FEATURES="${{ matrix.cargo_features || env.RELEASE_CARGO_FEATURES }}"
          if [ "${{ matrix.skip_prometheus || 'false' }}" = "true" ]; then
            cargo build --release --locked --no-default-features --features "${FEATURES},channel-nostr,skill-creation" --target ${{ matrix.target }}
          else
            cargo build --release --locked --features "${FEATURES}" --target ${{ matrix.target }}
          fi

      - name: Check binary size
        shell: bash
        run: bash scripts/ci/check_binary_size.sh "target/${{ matrix.target }}/release/${{ matrix.artifact }}" "${{ matrix.target }}"
        env:
          BINARY_SIZE_HARD_LIMIT: "52428800"  # 50MB — release builds include all optional features

      - name: Sign Windows binary
        if: runner.os == 'Windows'
        shell: pwsh
        env:
          WIN_SIGNING_PFX_BASE64: ${{ secrets.WIN_SIGNING_PFX_BASE64 }}
          WIN_SIGNING_PFX_PASSWORD: ${{ secrets.WIN_SIGNING_PFX_PASSWORD }}
        run: pwsh scripts/ci/sign_windows.ps1 -BinaryPath "target/${{ matrix.target }}/release/${{ matrix.artifact }}"

      # Smoke test: only run on native (non-cross) targets where the host can execute the binary.
      - name: Smoke test (Unix)
        if: runner.os != 'Windows' && !matrix.cross_compiler && !matrix.ndk
        shell: bash
        run: |
          BIN="target/${{ matrix.target }}/release/${{ matrix.artifact }}"
          echo "--- ${BIN} --version"
          "$BIN" --version
          echo "--- ${BIN} doctor (best effort)"
          "$BIN" doctor || echo "doctor not available on this build; skipping"

      - name: Smoke test (Windows)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          $bin = "target/${{ matrix.target }}/release/${{ matrix.artifact }}"
          Write-Host "--- $bin --version"
          & $bin --version
          if ($LASTEXITCODE -ne 0) { throw "version check failed (exit $LASTEXITCODE)" }
          Write-Host "--- $bin doctor (best effort)"
          # PowerShell try/catch does not catch native non-zero exits with the
          # default ErrorActionPreference, so check $LASTEXITCODE explicitly
          # and swallow any failure — doctor is best-effort.
          & $bin doctor
          if ($LASTEXITCODE -ne 0) { Write-Host "doctor returned exit $LASTEXITCODE; skipping" }
          $global:LASTEXITCODE = 0

      - name: Package (Unix)
        if: runner.os != 'Windows'
        run: |
          cd target/${{ matrix.target }}/release
          tar czf ../../../construct-${{ matrix.target }}.${{ matrix.ext }} ${{ matrix.artifact }}

      - name: Package (Windows)
        if: runner.os == 'Windows'
        run: |
          cd target/${{ matrix.target }}/release
          7z a ../../../construct-${{ matrix.target }}.${{ matrix.ext }} ${{ matrix.artifact }}

      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
        with:
          name: construct-${{ matrix.target }}
          path: construct-${{ matrix.target }}.${{ matrix.ext }}
          retention-days: 14

  build-desktop:
    name: Build Desktop App (macOS Universal)
    needs: [validate]
    runs-on: macos-14
    timeout-minutes: 40
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

      - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
        with:
          toolchain: 1.93.0
          targets: aarch64-apple-darwin,x86_64-apple-darwin

      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
        with:
          prefix-key: macos-tauri
          cache-bin: false

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Install Tauri CLI
        run: cargo install tauri-cli --locked

      - name: Sync Tauri version with Cargo.toml
        shell: bash
        run: |
          VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
          cd apps/tauri
          if command -v jq >/dev/null 2>&1; then
            jq --arg v "$VERSION" '.version = $v' tauri.conf.json > tmp.json && mv tmp.json tauri.conf.json
          else
            sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" tauri.conf.json
          fi
          echo "Tauri version set to: $VERSION"

      - name: Build Tauri app (universal binary)
        working-directory: apps/tauri
        run: cargo tauri build --target universal-apple-darwin

      - name: Prepare desktop release assets
        run: |
          mkdir -p desktop-assets
          find target -name '*.dmg' -exec cp {} desktop-assets/Construct.dmg \; 2>/dev/null || true
          find target -name '*.app.tar.gz' -exec cp {} desktop-assets/Construct-macos.app.tar.gz \; 2>/dev/null || true
          find target -name '*.app.tar.gz.sig' -exec cp {} desktop-assets/Construct-macos.app.tar.gz.sig \; 2>/dev/null || true
          echo "--- Desktop assets ---"
          ls -lh desktop-assets/

      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
        with:
          name: desktop-macos
          path: desktop-assets/*
          retention-days: 14

  sbom:
    name: Generate SBOM
    needs: [validate]
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
      - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
        with:
          toolchain: 1.93.0
      - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
        with:
          prefix-key: sbom-cyclonedx-stable
          cache-bin: false
      - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4
        with:
          node-version-file: web/.nvmrc

      - name: Install cargo-cyclonedx
        run: cargo install --locked cargo-cyclonedx
      - name: Generate Rust SBOM
        run: cargo cyclonedx --format json --override-filename sbom-rust.cdx || cargo cyclonedx --format json

      - name: Generate web SBOM
        run: |
          cd web
          npm ci
          npx --yes @cyclonedx/cyclonedx-npm --output-format json --output-file ../sbom-web.cdx.json

      - name: Collect SBOMs
        shell: bash
        run: |
          mkdir -p sbom-out
          find . -maxdepth 3 -type f \( -name 'sbom-rust*.cdx.json' -o -name 'bom.json' \) -print -exec cp {} sbom-out/sbom-rust.cdx.json \; || true
          cp sbom-web.cdx.json sbom-out/ 2>/dev/null || true
          ls -lh sbom-out/

      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
        with:
          name: sbom
          path: sbom-out/*
          retention-days: 14

  publish:
    name: Publish Stable Release
    needs: [validate, release-notes, build, build-desktop, sbom]
    runs-on: ubuntu-latest
    outputs:
      digests: ${{ steps.digests.outputs.digests }}
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
        with:
          pattern: construct-*
          path: artifacts

      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
        with:
          name: desktop-macos
          path: artifacts/desktop-macos

      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
        with:
          name: sbom
          path: artifacts/sbom

      - name: Verify required release assets
        shell: bash
        run: |
          required_assets=(
            "construct-x86_64-unknown-linux-gnu.tar.gz"
            "construct-aarch64-unknown-linux-gnu.tar.gz"
            "construct-armv7-unknown-linux-gnueabihf.tar.gz"
            "construct-arm-unknown-linux-gnueabihf.tar.gz"
            "construct-aarch64-apple-darwin.tar.gz"
            "construct-aarch64-linux-android.tar.gz"
            "construct-x86_64-pc-windows-msvc.zip"
            "Construct.dmg"
          )
          missing=0
          for asset in "${required_assets[@]}"; do
            if ! find artifacts -type f -name "$asset" -print -quit | grep -q .; then
              echo "::error::Missing required release asset: ${asset}"
              missing=1
            fi
          done
          if [ "$missing" -ne 0 ]; then
            echo "Collected files:"
            find artifacts -type f | sort
            exit 1
          fi

      - name: Generate checksums
        run: |
          cd artifacts
          find . -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.dmg' \) -exec sha256sum {} + | sed 's|  \./[^/]*/|  |' > SHA256SUMS
          cat SHA256SUMS

      - name: Install cosign
        uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
        with:
          cosign-release: v2.4.1

      - name: Sign SHA256SUMS (cosign keyless)
        env:
          COSIGN_YES: "true"
        run: |
          cd artifacts
          cosign sign-blob --yes \
            --bundle SHA256SUMS.bundle \
            --output-certificate SHA256SUMS.pem \
            --output-signature SHA256SUMS.sig \
            SHA256SUMS
          cosign verify-blob \
            --bundle SHA256SUMS.bundle \
            --certificate-identity-regexp "https://github.com/${{ github.repository }}/" \
            --certificate-oidc-issuer https://token.actions.githubusercontent.com \
            SHA256SUMS

      - name: Collect release assets
        run: |
          mkdir -p release-assets
          find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.dmg' \
            -o -name 'SHA256SUMS' -o -name 'SHA256SUMS.sig' -o -name 'SHA256SUMS.pem' -o -name 'SHA256SUMS.bundle' \
            -o -name '*.cdx.json' \) -exec cp {} release-assets/ \;
          cp install.sh release-assets/
          echo "--- Assets ---"
          ls -lh release-assets/

      - name: Compute digests for SLSA provenance
        id: digests
        shell: bash
        run: |
          cd release-assets
          digests=$(sha256sum *.tar.gz *.zip *.dmg 2>/dev/null | base64 -w0)
          echo "digests=${digests}" >> "$GITHUB_OUTPUT"

      - name: Write release notes
        env:
          NOTES: ${{ needs.release-notes.outputs.notes }}
        run: printf '%s\n' "$NOTES" > release-notes.md

      - name: Create tag if manual dispatch
        if: github.event_name == 'workflow_dispatch'
        env:
          TAG: ${{ needs.validate.outputs.tag }}
        run: |
          git tag -a "$TAG" -m "construct $TAG"
          git push origin "$TAG"

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
          TAG: ${{ needs.validate.outputs.tag }}
        run: |
          gh release create "$TAG" release-assets/* \
            --repo "${{ github.repository }}" \
            --title "$TAG" \
            --notes-file release-notes.md \
            --latest

  provenance:
    name: SLSA L3 Provenance
    needs: [validate, publish]
    permissions:
      actions: read
      id-token: write
      contents: write
    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
    with:
      base64-subjects: ${{ needs.publish.outputs.digests }}
      upload-assets: true
      upload-tag-name: ${{ needs.validate.outputs.tag }}
      draft-release: false

  crates-io:
    name: Publish to crates.io
    needs: [validate, publish]
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4

      - uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: 1.93.0

      - uses: Swatinem/rust-cache@v2
        with:
          cache-bin: false

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: web/package-lock.json

      - name: Build web dashboard
        run: cd web && npm ci && npm run build

      - name: Clean web build artifacts
        run: rm -rf web/node_modules web/src web/package.json web/package-lock.json web/tsconfig*.json web/vite.config.ts web/index.html

      - name: Check for crates.io token
        id: crates_token
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          if [ -z "$CARGO_REGISTRY_TOKEN" ]; then
            echo "::notice::CARGO_REGISTRY_TOKEN not set — skipping crates.io publish"
            echo "has_token=false" >> "$GITHUB_OUTPUT"
          else
            echo "has_token=true" >> "$GITHUB_OUTPUT"
          fi

      - name: Publish aardvark-sys to crates.io
        if: steps.crates_token.outputs.has_token == 'true'
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          OUTPUT=$(cargo publish --locked --allow-dirty --no-verify -p aardvark-sys 2>&1) && exit 0
          echo "$OUTPUT"
          if echo "$OUTPUT" | grep -q 'already exists'; then
            echo "::notice::aardvark-sys already on crates.io — skipping"
            exit 0
          fi
          exit 1

      - name: Wait for aardvark-sys to index
        if: steps.crates_token.outputs.has_token == 'true'
        run: sleep 15

      - name: Publish to crates.io
        if: steps.crates_token.outputs.has_token == 'true'
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
          VERSION: ${{ inputs.version }}
        run: |
          # Publish to crates.io; treat "already exists" as success
          # (auto-publish workflow may have already published this version)
          CRATE_NAME=$(sed -n 's/^name = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
          OUTPUT=$(cargo publish --locked --allow-dirty --no-verify 2>&1) && exit 0
          echo "$OUTPUT"
          if echo "$OUTPUT" | grep -q 'already exists'; then
            echo "::notice::${CRATE_NAME}@${VERSION} already on crates.io — skipping"
            exit 0
          fi
          exit 1

  redeploy-website:
    name: Trigger Website Redeploy
    needs: [publish]
    runs-on: ubuntu-latest
    steps:
      - name: Trigger website redeploy
        env:
          PAT: ${{ secrets.WEBSITE_REPO_PAT }}
        run: |
          if [ -z "$PAT" ]; then
            echo "WEBSITE_REPO_PAT not set — skipping website redeploy trigger."
            exit 0
          fi
          curl -fsSL -X POST \
            -H "Authorization: token $PAT" \
            -H "Accept: application/vnd.github+json" \
            https://api.github.com/repos/KumihoIO/construct-os-website/dispatches \
            -d '{"event_type":"new-release","client_payload":{"install_script_url":"https://raw.githubusercontent.com/KumihoIO/construct-os/main/install.sh"}}'

  docker:
    name: Push Docker Image
    needs: [validate, build]
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
        with:
          name: construct-x86_64-unknown-linux-gnu
          path: artifacts/

      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
        with:
          name: construct-aarch64-unknown-linux-gnu
          path: artifacts/

      - name: Prepare Docker context with pre-built binaries
        run: |
          mkdir -p docker-ctx/bin/amd64 docker-ctx/bin/arm64
          tar xzf artifacts/construct-x86_64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/amd64
          tar xzf artifacts/construct-aarch64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/arm64

          mkdir -p docker-ctx/construct-data/.construct docker-ctx/construct-data/workspace
          printf '%s\n' \
            'workspace_dir = "/construct-data/workspace"' \
            'config_path = "/construct-data/.construct/config.toml"' \
            'api_key = ""' \
            'default_provider = "openrouter"' \
            'default_model = "anthropic/claude-sonnet-4-20250514"' \
            'default_temperature = 0.7' \
            '' \
            '[gateway]' \
            'port = 42617' \
            'host = "[::]"' \
            'allow_public_bind = true' \
            > docker-ctx/construct-data/.construct/config.toml

          cp Dockerfile.ci docker-ctx/Dockerfile
          cp Dockerfile.debian.ci docker-ctx/Dockerfile.debian

      - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

      - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # GHCR rejects mixed-case repo names, so the
      # `${{ github.repository }}`-derived IMAGE_NAME (KumihoIO/...)
      # must be lowercased before the build-push step uses it.
      - name: Lowercase image name
        run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
        env:
          IMAGE_NAME: ${{ env.IMAGE_NAME }}

      - name: Build and push
        id: push-main
        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
        with:
          context: docker-ctx
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.tag }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          platforms: linux/amd64,linux/arm64

      - name: Build and push Debian compatibility image
        id: push-debian
        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
        with:
          context: docker-ctx
          file: docker-ctx/Dockerfile.debian
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.tag }}-debian
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:debian
          platforms: linux/amd64,linux/arm64

      - name: Install cosign
        uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
        with:
          cosign-release: v2.4.1

      - name: Sign images (keyless cosign via OIDC)
        env:
          COSIGN_YES: "true"
          DIGEST_MAIN: ${{ steps.push-main.outputs.digest }}
          DIGEST_DEBIAN: ${{ steps.push-debian.outputs.digest }}
        run: |
          set -e
          IMG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
          cosign sign --yes "${IMG}@${DIGEST_MAIN}"
          cosign sign --yes "${IMG}@${DIGEST_DEBIAN}"

      - name: Verify image signatures
        env:
          DIGEST_MAIN: ${{ steps.push-main.outputs.digest }}
          DIGEST_DEBIAN: ${{ steps.push-debian.outputs.digest }}
        run: |
          set -e
          IMG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
          cosign verify \
            --certificate-identity-regexp "https://github.com/${{ github.repository }}/" \
            --certificate-oidc-issuer https://token.actions.githubusercontent.com \
            "${IMG}@${DIGEST_MAIN}"
          cosign verify \
            --certificate-identity-regexp "https://github.com/${{ github.repository }}/" \
            --certificate-oidc-issuer https://token.actions.githubusercontent.com \
            "${IMG}@${DIGEST_DEBIAN}"

      # GHCR package visibility is an external setting and may take time to
      # propagate after push. The authenticated push and signature checks above
      # remain blocking; this anonymous pull check is advisory by default. Set
      # the repository variable REQUIRE_PUBLIC_GHCR_PULL=true to make it strict.
      - name: Verify public image pull
        env:
          TAG: ${{ needs.validate.outputs.tag }}
          REQUIRE_PUBLIC_GHCR_PULL: ${{ vars.REQUIRE_PUBLIC_GHCR_PULL || 'false' }}
        run: |
          set -u
          IMG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
          docker logout "${{ env.REGISTRY }}" >/dev/null 2>&1 || true

          refs=(
            "${IMG}:${TAG}"
            "${IMG}:latest"
            "${IMG}:${TAG}-debian"
            "${IMG}:debian"
          )
          failures=()

          for ref in "${refs[@]}"; do
            verified=false
            for attempt in 1 2 3 4 5; do
              if docker buildx imagetools inspect "$ref"; then
                verified=true
                break
              fi

              if [ "$attempt" -lt 5 ]; then
                sleep_for=$((attempt * 15))
                echo "::notice::Anonymous public pull check failed for ${ref}; retrying in ${sleep_for}s (${attempt}/5)"
                sleep "$sleep_for"
              fi
            done

            if [ "$verified" != true ]; then
              failures+=("$ref")
            fi
          done

          if [ "${#failures[@]}" -gt 0 ]; then
            printf 'Anonymous public pull verification failed for:\n'
            printf ' - %s\n' "${failures[@]}"

            if [ "${REQUIRE_PUBLIC_GHCR_PULL,,}" = "true" ]; then
              echo "::error::Set the GHCR package visibility to public, or unset REQUIRE_PUBLIC_GHCR_PULL to make this advisory."
              exit 1
            fi

            echo "::warning::GHCR rejected anonymous pulls. The image was already pushed and signature-verified with credentials; public visibility may still be private or propagating."
          fi

  # ── Post-publish: package manager auto-sync ─────────────────────────
  scoop:
    name: Update Scoop Manifest
    needs: [validate, publish]
    if: ${{ !cancelled() && needs.publish.result == 'success' }}
    uses: ./.github/workflows/pub-scoop.yml
    with:
      release_tag: ${{ needs.validate.outputs.tag }}
      dry_run: false
    secrets: inherit

  aur:
    name: Update AUR Package
    needs: [validate, publish]
    if: ${{ !cancelled() && needs.publish.result == 'success' }}
    uses: ./.github/workflows/pub-aur.yml
    with:
      release_tag: ${{ needs.validate.outputs.tag }}
      dry_run: false
    secrets: inherit

  # homebrew-core path disabled — Homebrew/homebrew-core's Acceptable Formulae
  # popularity bar (stars/watchers/forks) blocks new projects. Re-enable once
  # construct meets the criteria and a manual first-PR has landed upstream.
  # homebrew:
  #   name: Update Homebrew Core
  #   needs: [validate, publish]
  #   if: ${{ !cancelled() && needs.publish.result == 'success' }}
  #   uses: ./.github/workflows/pub-homebrew-core.yml
  #   with:
  #     release_tag: ${{ needs.validate.outputs.tag }}
  #     dry_run: false
  #   secrets: inherit

  homebrew-tap:
    name: Update Homebrew Tap
    needs: [validate, publish]
    if: ${{ !cancelled() && needs.publish.result == 'success' }}
    uses: ./.github/workflows/pub-homebrew-tap.yml
    with:
      release_tag: ${{ needs.validate.outputs.tag }}
      dry_run: false
    secrets: inherit

  # ── Post-publish: sync marketplace templates (Coolify, Dokploy, EasyPanel) ──
  marketplace:
    name: Sync Marketplace Templates
    needs: [validate, docker]
    if: ${{ !cancelled() && needs.docker.result == 'success' }}
    uses: ./.github/workflows/sync-marketplace-templates.yml
    with:
      release_tag: ${{ needs.validate.outputs.tag }}
    secrets: inherit

  # ── Post-publish: announce after release + website are live ───────────
  # Docker push can be slow; don't let it block announcements.
  #
  # NOTE: tweet + discord announcements are temporarily disabled for the
  # v2026.4.29 bug-fix release. Re-enable in the next release-prep PR.
  # tweet:
  #   name: Tweet Release
  #   needs: [validate, publish, redeploy-website]
  #   if: ${{ !cancelled() && needs.publish.result == 'success' }}
  #   uses: ./.github/workflows/tweet-release.yml
  #   with:
  #     release_tag: ${{ needs.validate.outputs.tag }}
  #     release_url: https://github.com/KumihoIO/construct-os/releases/tag/${{ needs.validate.outputs.tag }}
  #   secrets: inherit
  #
  # discord:
  #   name: Discord Announcement
  #   needs: [validate, publish, redeploy-website]
  #   if: ${{ !cancelled() && needs.publish.result == 'success' }}
  #   uses: ./.github/workflows/discord-release.yml
  #   with:
  #     release_tag: ${{ needs.validate.outputs.tag }}
  #     release_url: https://github.com/KumihoIO/construct-os/releases/tag/${{ needs.validate.outputs.tag }}
  #   secrets: inherit
