From 9b5fc40b2efecfe44308290bbc48e599fad73d98 Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:07:36 -0500 Subject: [PATCH] Shard FMA validation workflows and route Windows apps to arch-matched runners (#49348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Related issue:** N/A — CI improvement for the FMA validation workflows. ## Summary Restructures the Windows and macOS Fleet-maintained app validation workflows around a cheap Linux detect/shard job, with Windows apps additionally routed to a CI runner whose native architecture matches the app's installer. **Both platforms:** - Change detection and sharding run on `ubuntu-latest`. Expensive Windows/macOS runners only spin up when their platform actually has changed apps — e.g. a Windows-only letter-batch PR no longer boots a macOS runner just to discover there's nothing to do (and vice versa) — and they check out at depth 1 instead of full history. - A new `.github/scripts/partition-fma-apps.sh ` emits the job matrix; validation steps move unchanged into reusable workflows (`test-fma-windows-validate.yml`, `test-fma-darwin-validate.yml`). - Large PRs shard into parallel jobs (Windows: 25 apps/shard, macOS: 30), and the manual full-run workflows gain a `shard_size` input (Windows default 20 → ~20 shards over 384 apps; macOS default 25 → ~39 shards over 961 apps). Neither full run could previously finish: hundreds of sequential installs blow the 6-hour job limit. - Pre-installed app handling is computed per shard from that shard's slug list — Windows removals (Chrome, 7-Zip, Firefox, Node.js, PowerShell, R, Git) and macOS steps (Chrome, Xcode for Icon Composer, the Fleet Desktop MDM config stub) only run on the runner validating that app. This also brings the full-run workflows to parity with the PR gates (they previously only removed Chrome). - Stable summary jobs (`test-fma-pr-only`, `test-fma`) aggregate the dynamic matrix results so branch protection / PR gating keeps a fixed check name. **Windows arch routing:** - Each changed `/windows` slug's `installer_arch` is read from `ee/maintained-apps/inputs/winget/.json`: `arm64` apps → `windows-11-arm`, x64/x86/neutral apps → `windows-latest` (x64). Missing input files default to x64 with a warning. This fixes installers that check the native OS architecture and abort under Prism emulation on the ARM runner (Inno Setup `ArchitecturesAllowed=x64` — GOG Galaxy, Reqable — and Docker Desktop). Future arm64 FMAs need no workflow change — `installer_arch: arm64` in the winget input is enough. - macOS needs no arch matrix: `macos-latest` is arm64 and x86-only casks run under Rosetta 2, which matches how customer Macs run them. # Checklist for submitter - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [x] QA'd all new/changed functionality manually Manual QA: - Partition script is shellcheck-clean and tested against the real repo for both platforms: empty input, mixed-platform slug lists, x86/neutral routing to the x64 runner, single-slug arrays, missing input file fallback, arm64/x64 split with sharding (via a synthetic arm64 input), invalid platform/shard-size rejection, and full-catalog partitions (384 Windows apps → 20 shards, 961 darwin apps → 39 shards, all slugs accounted for, matrix outputs well under the 1 MB job-output limit). - All six workflows pass `actionlint` and zizmor 1.25.2 (with the repo's `.github/zizmor-gate.yml` config) with no findings. - The rewritten Windows PR gate ran on this PR itself: the Linux detect job correctly found no changed Windows apps, skipped the Windows runners, and the `test-fma-pr-only` summary check passed. ## Summary by CodeRabbit * **New Features** * Added sharded validation for maintained macOS and Windows apps to run tests in parallel. * Added configurable `shard_size` for manual validation runs. * Introduced reusable validation workflows for Darwin and Windows. * Improved Windows testing to be architecture-aware (ARM64 vs x64). * **Bug Fixes** * Improved pull request gating to validate only changed apps and report results more reliably. * Workflows now gracefully handle scenarios where no matching apps are found (avoid unnecessary failures). --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/scripts/partition-fma-apps.sh | 155 ++++ .github/workflows/test-fma-darwin-pr-only.yml | 247 ++---- .../workflows/test-fma-darwin-validate.yml | 170 ++++ .github/workflows/test-fma-darwin.yml | 137 ++- .../workflows/test-fma-windows-pr-only.yml | 779 ++---------------- .../workflows/test-fma-windows-validate.yml | 657 +++++++++++++++ .github/workflows/test-fma-windows.yml | 129 +-- 7 files changed, 1247 insertions(+), 1027 deletions(-) create mode 100755 .github/scripts/partition-fma-apps.sh create mode 100644 .github/workflows/test-fma-darwin-validate.yml create mode 100644 .github/workflows/test-fma-windows-validate.yml diff --git a/.github/scripts/partition-fma-apps.sh b/.github/scripts/partition-fma-apps.sh new file mode 100755 index 0000000000..c8f0cd9273 --- /dev/null +++ b/.github/scripts/partition-fma-apps.sh @@ -0,0 +1,155 @@ +#!/bin/bash + +# Partition FMA slugs for a platform into a GitHub Actions job matrix whose +# entries route apps to an appropriate runner, splitting buckets larger than +# the shard size into shards that validate in parallel. +# +# windows Apps are routed to a runner with the matching native installer +# architecture: arm64 apps to windows-11-arm, everything else +# (x64, x86, neutral) to the x64 runner. The architecture for a +# slug like "7-zip/windows" is read from +# ee/maintained-apps/inputs/winget/7-zip.json (.installer_arch). +# Slugs whose input file or installer_arch is missing default to +# the x64 runner. +# darwin All apps run on macos-latest (arm64; x86-only casks run under +# Rosetta 2, matching how customer Macs run them). No architecture +# partitioning is needed. +# +# Usage: partition-fma-apps.sh [shard_size] +# +# Like filter-apps-json.sh, the slugs argument is either a literal JSON array +# string or a path to a file containing one. Slugs for other platforms are +# ignored. +# +# Outputs (appended to $GITHUB_OUTPUT): +# has_apps - "true" or "false" +# matrix - JSON array of {name, runner, slugs} objects, where slugs is a +# JSON-encoded array string for that shard. Windows entries also +# carry an "arch" field. + +set -euo pipefail + +WINDOWS_X64_RUNNER="windows-latest" +WINDOWS_ARM64_RUNNER="windows-11-arm" +DARWIN_RUNNER="macos-latest" + +REPO_ROOT="${GITHUB_WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +WINGET_INPUTS_DIR="${REPO_ROOT}/ee/maintained-apps/inputs/winget" +GITHUB_OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" + +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed" >&2 + exit 1 +fi + +PLATFORM="${1:-}" +SLUGS_INPUT="${2:-[]}" +SHARD_SIZE="${3:-25}" + +if [ "$PLATFORM" != "windows" ] && [ "$PLATFORM" != "darwin" ]; then + echo "Error: platform must be 'windows' or 'darwin', got '$PLATFORM'" >&2 + exit 1 +fi + +if [ -n "$SLUGS_INPUT" ] && [ -f "$SLUGS_INPUT" ]; then + SLUGS_JSON="$(cat "$SLUGS_INPUT")" +else + SLUGS_JSON="$SLUGS_INPUT" +fi +if [ -z "$SLUGS_JSON" ] || [ "$SLUGS_JSON" == "null" ]; then + SLUGS_JSON="[]" +fi + +if ! [[ "$SHARD_SIZE" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: shard size must be a positive integer, got '$SHARD_SIZE'" >&2 + exit 1 +fi + +PLATFORM_SLUGS_JSON=$(jq -c --arg suffix "/${PLATFORM}" '[.[] | select(endswith($suffix))] | unique' <<< "$SLUGS_JSON") +TOTAL=$(jq 'length' <<< "$PLATFORM_SLUGS_JSON") + +if [ "$TOTAL" -eq 0 ]; then + echo "No ${PLATFORM} apps to validate." + echo "has_apps=false" >> "$GITHUB_OUTPUT" + echo "matrix=[]" >> "$GITHUB_OUTPUT" + exit 0 +fi + +ENTRIES_FILE="$(mktemp)" +trap 'rm -f "$ENTRIES_FILE"' EXIT + +# emit_shards +# arch is embedded in the matrix entry when non-empty (windows only). +emit_shards() { + local bucket="$1" runner="$2" arch="$3" + shift 3 + local slugs=("$@") + local total=${#slugs[@]} + [ "$total" -eq 0 ] && return 0 + local shards=$(( (total + SHARD_SIZE - 1) / SHARD_SIZE )) + local i=0 shard=1 + while [ "$i" -lt "$total" ]; do + local chunk=("${slugs[@]:$i:$SHARD_SIZE}") + local chunk_json + chunk_json=$(printf '%s\n' "${chunk[@]}" | jq -R . | jq -s -c .) + local name="$bucket" + if [ "$shards" -gt 1 ]; then + name="$bucket (${shard}/${shards})" + fi + jq -c -n --arg name "$name" --arg runner "$runner" --arg arch "$arch" --arg slugs "$chunk_json" \ + '{name: $name, runner: $runner, slugs: $slugs} + (if $arch != "" then {arch: $arch} else {} end)' >> "$ENTRIES_FILE" + i=$((i + SHARD_SIZE)) + shard=$((shard + 1)) + done +} + +case "$PLATFORM" in + windows) + x64_slugs=() + arm64_slugs=() + while IFS= read -r slug; do + [ -z "$slug" ] && continue + name="${slug%/windows}" + input_file="${WINGET_INPUTS_DIR}/${name}.json" + arch="" + if [ -f "$input_file" ]; then + arch=$(jq -r '.installer_arch // empty' "$input_file" 2>/dev/null || echo "") + else + echo "Warning: no winget input file for '$slug' at $input_file, assuming x64" >&2 + fi + case "$arch" in + arm64) + arm64_slugs+=("$slug") + ;; + *) + # x64, x86 and neutral installers all run natively on the x64 runner. + x64_slugs+=("$slug") + ;; + esac + echo " - $slug -> ${arch:-x64}" + done < <(jq -r '.[]' <<< "$PLATFORM_SLUGS_JSON") + + emit_shards "x64" "$WINDOWS_X64_RUNNER" "x64" ${x64_slugs[@]+"${x64_slugs[@]}"} + emit_shards "arm64" "$WINDOWS_ARM64_RUNNER" "arm64" ${arm64_slugs[@]+"${arm64_slugs[@]}"} + + echo "Windows apps to validate: $TOTAL (x64/x86/neutral: ${#x64_slugs[@]}, arm64: ${#arm64_slugs[@]})" + ;; + darwin) + darwin_slugs=() + while IFS= read -r slug; do + [ -z "$slug" ] && continue + darwin_slugs+=("$slug") + echo " - $slug" + done < <(jq -r '.[]' <<< "$PLATFORM_SLUGS_JSON") + + emit_shards "darwin" "$DARWIN_RUNNER" "" ${darwin_slugs[@]+"${darwin_slugs[@]}"} + + echo "Darwin apps to validate: $TOTAL" + ;; +esac + +MATRIX_JSON=$(jq -c -s . "$ENTRIES_FILE") +echo "Matrix: $MATRIX_JSON" + +echo "has_apps=true" >> "$GITHUB_OUTPUT" +echo "matrix=${MATRIX_JSON}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/test-fma-darwin-pr-only.yml b/.github/workflows/test-fma-darwin-pr-only.yml index 46c46cedea..14610ecae3 100644 --- a/.github/workflows/test-fma-darwin-pr-only.yml +++ b/.github/workflows/test-fma-darwin-pr-only.yml @@ -7,6 +7,11 @@ on: - ee/maintained-apps/inputs/** - ee/maintained-apps/outputs/** - cmd/maintained-apps/validate/** + - .github/workflows/test-fma-darwin-pr-only.yml + - .github/workflows/test-fma-darwin-validate.yml + - .github/scripts/partition-fma-apps.sh + - .github/scripts/detect-new-fmas-in-pr.sh + - .github/scripts/filter-apps-json.sh workflow_dispatch: # Manual trigger inputs: log_level: @@ -20,18 +25,20 @@ on: - warn - error -env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - permissions: contents: read jobs: - test-fma-pr-only: - env: - LOG_LEVEL: ${{ github.event.inputs.log_level || 'info' }} - runs-on: macos-latest - + # Detect which apps changed and shard them on a cheap Linux runner. The + # (much more expensive) macOS runners below only spin up when there are + # darwin apps to validate — Windows-only FMA PRs no longer boot a macOS + # runner just to discover there is nothing to do. Large PRs are split into + # shards that validate in parallel. + detect-changed-apps: + runs-on: ubuntu-latest + outputs: + has_darwin_apps: ${{ steps.partition.outputs.has_apps }} + matrix: ${{ steps.partition.outputs.matrix }} steps: - name: Harden Runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -41,185 +48,69 @@ jobs: - name: Checkout Fleet uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - repository: fleetdm/fleet fetch-depth: 0 # Need full history to compare with base branch - ref: ${{ github.ref }} - path: fleet persist-credentials: false - - name: Setup Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 - with: - go-version-file: "fleet/go.mod" - - - name: Fetch base branch - run: | - cd fleet - BASE_BRANCH="${{ github.event.pull_request.base.ref || github.base_ref || 'main' }}" - echo "Fetching base branch: $BASE_BRANCH" - git fetch origin "$BASE_BRANCH:$BASE_BRANCH" || true - shell: bash - - name: Detect changed apps id: detect-changed env: GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }} run: | - cd fleet - export GITHUB_WORKSPACE="$PWD" - .github/scripts/detect-new-fmas-in-pr.sh - shell: bash + # fetch-depth 0 normally brings in the base branch already; fetch it + # explicitly as a fallback so origin/$GITHUB_BASE_REF exists. + git fetch origin "$GITHUB_BASE_REF" || true + bash .github/scripts/detect-new-fmas-in-pr.sh - - name: Check if there are changes - id: check-changes + - name: Shard Darwin apps + id: partition + env: + CHANGED_APPS: ${{ steps.detect-changed.outputs.CHANGED_APPS }} run: | - # Default to no changes if detection step failed or didn't set output - HAS_CHANGES="${{ steps.detect-changed.outputs.HAS_CHANGES }}" - if [ "$HAS_CHANGES" == "true" ]; then - echo "has_changes=true" >> $GITHUB_OUTPUT - echo "Changed apps detected: ${{ steps.detect-changed.outputs.CHANGED_APPS }}" - else - echo "has_changes=false" >> $GITHUB_OUTPUT - echo "No changed apps detected, skipping validation" + bash .github/scripts/partition-fma-apps.sh darwin "${CHANGED_APPS:-[]}" 30 + + validate: + needs: detect-changed-apps + if: needs.detect-changed-apps.outputs.has_darwin_apps == 'true' + name: ${{ matrix.name }} + strategy: + # Don't cancel the other shards' validation if one fails. + fail-fast: false + matrix: + include: ${{ fromJSON(needs.detect-changed-apps.outputs.matrix) }} + uses: ./.github/workflows/test-fma-darwin-validate.yml + permissions: + contents: read + with: + runner: ${{ matrix.runner }} + slugs: ${{ matrix.slugs }} + log_level: ${{ github.event.inputs.log_level || 'info' }} + + # Stable-named summary check (matches the old single-job name) so PR gating + # doesn't depend on the dynamic per-shard matrix job names. + test-fma-pr-only: + needs: [detect-changed-apps, validate] + if: always() + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Check validation results + env: + DETECT_RESULT: ${{ needs.detect-changed-apps.result }} + VALIDATE_RESULT: ${{ needs.validate.result }} + run: | + echo "detect-changed-apps: $DETECT_RESULT" + echo "validate: $VALIDATE_RESULT" + if [ "$DETECT_RESULT" != "success" ]; then + echo "Detecting changed apps failed" + exit 1 fi - - - name: Check if there are Darwin apps - id: check-darwin-apps - run: | - if [ "${{ steps.check-changes.outputs.has_changes }}" != "true" ]; then - echo "has_darwin_apps=false" >> $GITHUB_OUTPUT - echo "has_google_chrome=false" >> $GITHUB_OUTPUT - echo "has_icon_composer=false" >> $GITHUB_OUTPUT - exit 0 + # validate is skipped when the PR changes no darwin apps. + if [ "$VALIDATE_RESULT" != "success" ] && [ "$VALIDATE_RESULT" != "skipped" ]; then + echo "Validation failed" + exit 1 fi - - # Filter changed apps to only include darwin platform - DARWIN_SLUGS=$(echo '${{ steps.detect-changed.outputs.CHANGED_APPS }}' | jq -r '.[] | select(endswith("/darwin"))') - - if [ -z "$DARWIN_SLUGS" ]; then - echo "has_darwin_apps=false" >> $GITHUB_OUTPUT - echo "has_google_chrome=false" >> $GITHUB_OUTPUT - echo "has_fleet_desktop=false" >> $GITHUB_OUTPUT - echo "has_icon_composer=false" >> $GITHUB_OUTPUT - echo "No darwin apps changed, skipping Darwin workflow" - else - echo "has_darwin_apps=true" >> $GITHUB_OUTPUT - echo "Darwin apps detected:" - echo "$DARWIN_SLUGS" | while read -r slug; do - echo " - $slug" - done - - # Check if google-chrome/darwin is in the changed apps - if echo "$DARWIN_SLUGS" | grep -q "^google-chrome/darwin$"; then - echo "has_google_chrome=true" >> $GITHUB_OUTPUT - echo "Google Chrome detected in changed apps" - else - echo "has_google_chrome=false" >> $GITHUB_OUTPUT - fi - - # Check if fleet-desktop/darwin is in the changed apps - if echo "$DARWIN_SLUGS" | grep -q "^fleet-desktop/darwin$"; then - echo "has_fleet_desktop=true" >> $GITHUB_OUTPUT - echo "Fleet Desktop detected in changed apps" - else - echo "has_fleet_desktop=false" >> $GITHUB_OUTPUT - fi - - # Check if icon-composer/darwin is in the changed apps - if echo "$DARWIN_SLUGS" | grep -q "^icon-composer/darwin$"; then - echo "has_icon_composer=true" >> $GITHUB_OUTPUT - echo "Icon Composer detected in changed apps" - else - echo "has_icon_composer=false" >> $GITHUB_OUTPUT - fi - fi - shell: bash - - - name: Install osquery mac - if: steps.check-darwin-apps.outputs.has_darwin_apps == 'true' - run: | - echo "Runner architecture: $(uname -m)" - curl -L -o osquery.tar.gz "https://github.com/osquery/osquery/releases/download/5.18.1/osquery-5.18.1_1.macos_arm64.tar.gz" - tar -xzf osquery.tar.gz - sudo cp -r opt / - sudo cp -r private / - sudo ln -sf /opt/osquery/lib/osquery.app/Contents/MacOS/osqueryd /usr/local/bin/osqueryi - sudo ln -sf /opt/osquery/lib/osquery.app/Contents/Resources/osqueryctl /usr/local/bin/osqueryctl - - - name: Remove pre-installed google chrome mac - if: steps.check-darwin-apps.outputs.has_darwin_apps == 'true' && steps.check-darwin-apps.outputs.has_google_chrome == 'true' - run: | - ls /Applications | grep -i "Chrome" - find /Applications -name "*Chrome*.app" -type d | while read app; - do - echo "Removing $app..." - sudo rm -rf "$app" - done - - # Icon Composer ships bundled inside Xcode, and GitHub macOS runners - # come with Xcode pre-installed. Remove it so the Icon Composer FMA - # install script is validated against a clean install rather than an - # already-present copy. Only runs when icon-composer/darwin is being - # validated in this PR. - - name: Remove pre-installed Xcode mac - if: steps.check-darwin-apps.outputs.has_darwin_apps == 'true' && steps.check-darwin-apps.outputs.has_icon_composer == 'true' - run: | - ls /Applications | grep -i "Xcode" || true - find /Applications -maxdepth 1 -iname "Xcode*.app" -type d | while read app; - do - echo "Removing $app..." - sudo rm -rf "$app" - done - - # Fleet Desktop's installer refuses to run unless the - # com.fleetdm.fleetd.config managed preferences profile is present - # (it's normally delivered via MDM). CI runners aren't MDM-enrolled, - # so we drop a stub plist in place before the validate step so the - # install script succeeds. This only runs when fleet-desktop/darwin - # is actually being validated in this PR. - - name: Create Fleet Desktop MDM config stub (CI-only) - if: steps.check-darwin-apps.outputs.has_darwin_apps == 'true' && steps.check-darwin-apps.outputs.has_fleet_desktop == 'true' - run: | - sudo mkdir -p "/Library/Managed Preferences" - sudo tee "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist" > /dev/null <<'PLIST' - - - - - EnrollSecret - ci-test-placeholder - FleetURL - https://ci.test.example.com - - - PLIST - sudo chmod 644 "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist" - ls -l "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist" - - - name: Filter apps.json and verify changed apps - if: steps.check-darwin-apps.outputs.has_darwin_apps == 'true' - run: | - cd fleet - # Set GITHUB_WORKSPACE to current directory so scripts can find files - export GITHUB_WORKSPACE="$PWD" - - # Filter changed apps to only include darwin platform - DARWIN_SLUGS=$(echo '${{ steps.detect-changed.outputs.CHANGED_APPS }}' | jq -r '.[] | select(endswith("/darwin"))') - DARWIN_SLUGS_JSON=$(echo "$DARWIN_SLUGS" | jq -R -s -c 'split("\n") | map(select(length > 0))') - - # Backup original apps.json - cp ee/maintained-apps/outputs/apps.json ee/maintained-apps/outputs/apps.json.backup - - # Create filtered apps.json - FILTERED_APPS_JSON=$(mktemp) - .github/scripts/filter-apps-json.sh "$DARWIN_SLUGS_JSON" "$FILTERED_APPS_JSON" - - # Replace apps.json with filtered version - mv "$FILTERED_APPS_JSON" ee/maintained-apps/outputs/apps.json - - # Run validation - ls /Applications - sudo -E go run ./cmd/maintained-apps/validate - - # Restore original apps.json - mv ee/maintained-apps/outputs/apps.json.backup ee/maintained-apps/outputs/apps.json + echo "All Darwin FMA validations passed" diff --git a/.github/workflows/test-fma-darwin-validate.yml b/.github/workflows/test-fma-darwin-validate.yml new file mode 100644 index 0000000000..5f8f50c2fe --- /dev/null +++ b/.github/workflows/test-fma-darwin-validate.yml @@ -0,0 +1,170 @@ +# Reusable workflow that installs and validates a set of macOS Fleet-maintained +# apps. Called by test-fma-darwin-pr-only.yml and test-fma-darwin.yml with a +# matrix produced by .github/scripts/partition-fma-apps.sh. All darwin apps run +# on the arm64 macos-latest runner (x86-only casks run under Rosetta 2, which +# matches how customer Macs run them), so unlike Windows there is no +# per-architecture routing. +name: Validate Fleet Maintained Apps - Darwin + +on: + workflow_call: + inputs: + runner: + description: 'Runner label to validate on (e.g. "macos-latest")' + required: true + type: string + slugs: + description: 'JSON array of app slugs to validate (e.g. ["box-drive/darwin"])' + required: true + type: string + log_level: + description: "Log level (debug, info, warn, error)" + required: false + type: string + default: "info" + +env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LOG_LEVEL: ${{ inputs.log_level }} + +permissions: + contents: read + +jobs: + validate: + runs-on: ${{ inputs.runner }} + + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + # Changed-app detection and sharding happen in the calling workflow on a + # Linux runner, so no git history is needed here. + - name: Checkout Fleet + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + path: fleet + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: "fleet/go.mod" + + - name: Determine pre-installed apps to remove + id: check-darwin-apps + # Pass the slugs through env rather than expanding ${{ inputs.slugs }} + # into the script body (flagged by zizmor as template injection). + env: + SLUGS_JSON: ${{ inputs.slugs }} + run: | + echo "Apps to validate on this $(uname -m) runner:" + echo "$SLUGS_JSON" | jq -r '.[] | " - \(.)"' + + # The runner images ship with some of the apps we validate already + # installed (or bundled, in Icon Composer's case); flag the ones + # present in this shard so the steps below start the validator from + # a clean state. + has_flag() { + echo "$SLUGS_JSON" | jq -e --arg slug "$1" 'index($slug) != null' > /dev/null && echo "true" || echo "false" + } + { + echo "has_google_chrome=$(has_flag 'google-chrome/darwin')" + echo "has_icon_composer=$(has_flag 'icon-composer/darwin')" + echo "has_fleet_desktop=$(has_flag 'fleet-desktop/darwin')" + } >> "$GITHUB_OUTPUT" + shell: bash + + - name: Install osquery mac + run: | + echo "Runner architecture: $(uname -m)" + curl -L -o osquery.tar.gz "https://github.com/osquery/osquery/releases/download/5.18.1/osquery-5.18.1_1.macos_arm64.tar.gz" + tar -xzf osquery.tar.gz + sudo cp -r opt / + sudo cp -r private / + sudo ln -sf /opt/osquery/lib/osquery.app/Contents/MacOS/osqueryd /usr/local/bin/osqueryi + sudo ln -sf /opt/osquery/lib/osquery.app/Contents/Resources/osqueryctl /usr/local/bin/osqueryctl + + - name: Remove pre-installed google chrome mac + if: steps.check-darwin-apps.outputs.has_google_chrome == 'true' + run: | + find /Applications -maxdepth 1 -iname "*chrome*" + find /Applications -name "*Chrome*.app" -type d | while read -r app; + do + echo "Removing $app..." + sudo rm -rf "$app" + done + + # Icon Composer ships bundled inside Xcode, and GitHub macOS runners + # come with Xcode pre-installed. Remove it so the Icon Composer FMA + # install script is validated against a clean install rather than an + # already-present copy. Only runs when icon-composer/darwin is being + # validated in this shard. + - name: Remove pre-installed Xcode mac + if: steps.check-darwin-apps.outputs.has_icon_composer == 'true' + run: | + find /Applications -maxdepth 1 -iname "Xcode*" + find /Applications -maxdepth 1 -iname "Xcode*.app" -type d | while read -r app; + do + echo "Removing $app..." + sudo rm -rf "$app" + done + + # Fleet Desktop's installer refuses to run unless the + # com.fleetdm.fleetd.config managed preferences profile is present + # (it's normally delivered via MDM). CI runners aren't MDM-enrolled, + # so we drop a stub plist in place before the validate step so the + # install script succeeds. This only runs when fleet-desktop/darwin + # is actually being validated in this shard. + - name: Create Fleet Desktop MDM config stub (CI-only) + if: steps.check-darwin-apps.outputs.has_fleet_desktop == 'true' + run: | + sudo mkdir -p "/Library/Managed Preferences" + sudo tee "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist" > /dev/null <<'PLIST' + + + + + EnrollSecret + ci-test-placeholder + FleetURL + https://ci.test.example.com + + + PLIST + sudo chmod 644 "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist" + ls -l "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist" + + - name: Filter apps.json and validate apps + # Pass the slugs through env rather than expanding ${{ inputs.slugs }} + # into the script body (flagged by zizmor as template injection). + env: + SLUGS_JSON: ${{ inputs.slugs }} + run: | + cd fleet + # Set GITHUB_WORKSPACE to current directory so scripts can find files + export GITHUB_WORKSPACE="$PWD" + + # The shard's slugs arrive as a compact JSON array string built by + # the partition script, ready for filter-apps-json.sh as-is. + echo "Filtering apps.json for slugs: $SLUGS_JSON" + + # Backup original apps.json + cp ee/maintained-apps/outputs/apps.json ee/maintained-apps/outputs/apps.json.backup + + # Create filtered apps.json + FILTERED_APPS_JSON=$(mktemp) + .github/scripts/filter-apps-json.sh "$SLUGS_JSON" "$FILTERED_APPS_JSON" + + # Replace apps.json with filtered version + mv "$FILTERED_APPS_JSON" ee/maintained-apps/outputs/apps.json + + # Run validation + ls /Applications + sudo -E go run ./cmd/maintained-apps/validate + + # Restore original apps.json + mv ee/maintained-apps/outputs/apps.json.backup ee/maintained-apps/outputs/apps.json diff --git a/.github/workflows/test-fma-darwin.yml b/.github/workflows/test-fma-darwin.yml index b2230168dc..b9e80a0280 100644 --- a/.github/workflows/test-fma-darwin.yml +++ b/.github/workflows/test-fma-darwin.yml @@ -15,19 +15,25 @@ on: - info - warn - error - -env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shard_size: + description: "Maximum number of apps per validation job (shards run in parallel)" + required: false + default: "20" + type: string permissions: contents: read jobs: - test-fma: - env: - LOG_LEVEL: ${{ github.event.inputs.log_level || 'info' }} - runs-on: macos-latest - + # Shard every darwin app in apps.json on a cheap Linux runner, then fan out + # to macOS runners that validate the shards in parallel. Note that GitHub + # caps concurrent macOS jobs well below Linux/Windows, so shards beyond the + # cap queue and run in waves. + partition: + runs-on: ubuntu-latest + outputs: + has_darwin_apps: ${{ steps.partition.outputs.has_apps }} + matrix: ${{ steps.partition.outputs.matrix }} steps: - name: Harden Runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -37,74 +43,59 @@ jobs: - name: Checkout Fleet uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - repository: fleetdm/fleet fetch-depth: 1 - ref: ${{ github.ref }} - path: fleet persist-credentials: false - - name: Setup Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + - name: Shard all Darwin apps + id: partition + env: + SHARD_SIZE: ${{ github.event.inputs.shard_size || '25' }} + run: | + ALL_DARWIN_SLUGS=$(jq -c '[.apps[].slug | select(endswith("/darwin"))]' ee/maintained-apps/outputs/apps.json) + bash .github/scripts/partition-fma-apps.sh darwin "$ALL_DARWIN_SLUGS" "$SHARD_SIZE" + + validate: + needs: partition + if: needs.partition.outputs.has_darwin_apps == 'true' + name: ${{ matrix.name }} + strategy: + # Keep validating the remaining shards even if one fails so a full run + # reports every broken app, not just the first. + fail-fast: false + matrix: + include: ${{ fromJSON(needs.partition.outputs.matrix) }} + uses: ./.github/workflows/test-fma-darwin-validate.yml + permissions: + contents: read + with: + runner: ${{ matrix.runner }} + slugs: ${{ matrix.slugs }} + log_level: ${{ github.event.inputs.log_level || 'info' }} + + # Stable-named summary job aggregating the per-shard results. + test-fma: + needs: [partition, validate] + if: always() + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: - go-version-file: "fleet/go.mod" + egress-policy: audit - - name: Install osquery mac + - name: Check validation results + env: + PARTITION_RESULT: ${{ needs.partition.result }} + VALIDATE_RESULT: ${{ needs.validate.result }} run: | - echo "Runner architecture: $(uname -m)" - curl -L -o osquery.tar.gz "https://github.com/osquery/osquery/releases/download/5.18.1/osquery-5.18.1_1.macos_arm64.tar.gz" - tar -xzf osquery.tar.gz - sudo cp -r opt / - sudo cp -r private / - sudo ln -sf /opt/osquery/lib/osquery.app/Contents/MacOS/osqueryd /usr/local/bin/osqueryi - sudo ln -sf /opt/osquery/lib/osquery.app/Contents/Resources/osqueryctl /usr/local/bin/osqueryctl - - - name: Remove pre-installed google chrome mac - run: | - ls /Applications | grep -i "Chrome" - find /Applications -name "*Chrome*.app" -type d | while read app; - do - echo "Removing $app..." - sudo rm -rf "$app" - done - - # Icon Composer ships bundled inside Xcode, and GitHub macOS runners - # come with Xcode pre-installed. Only runs when icon-composer/darwin is being - # validated in this PR. - - name: Remove pre-installed Xcode mac - run: | - ls /Applications | grep -i "Xcode" || true - find /Applications -maxdepth 1 -iname "Xcode*.app" -type d | while read app; - do - echo "Removing $app..." - sudo rm -rf "$app" - done - - # Fleet Desktop's installer refuses to run unless the - # com.fleetdm.fleetd.config managed preferences profile is present - # (it's normally delivered via MDM). CI runners aren't MDM-enrolled, - # so we drop a stub plist in place before the validate step so the - # install script succeeds. This workflow validates every FMA every - # run, so fleet-desktop is always exercised. - - name: Create Fleet Desktop MDM config stub (CI-only) - run: | - sudo mkdir -p "/Library/Managed Preferences" - sudo tee "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist" > /dev/null <<'PLIST' - - - - - EnrollSecret - ci-test-placeholder - FleetURL - https://ci.test.example.com - - - PLIST - sudo chmod 644 "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist" - ls -l "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist" - - - name: Verify Fleet Maintained Apps mac - run: | - ls /Applications - cd fleet - sudo -E go run ./cmd/maintained-apps/validate + echo "partition: $PARTITION_RESULT" + echo "validate: $VALIDATE_RESULT" + if [ "$PARTITION_RESULT" != "success" ]; then + echo "Sharding apps failed" + exit 1 + fi + if [ "$VALIDATE_RESULT" != "success" ] && [ "$VALIDATE_RESULT" != "skipped" ]; then + echo "Validation failed" + exit 1 + fi + echo "All Darwin FMA validations passed" diff --git a/.github/workflows/test-fma-windows-pr-only.yml b/.github/workflows/test-fma-windows-pr-only.yml index 6260618a7b..7ffcc73169 100644 --- a/.github/workflows/test-fma-windows-pr-only.yml +++ b/.github/workflows/test-fma-windows-pr-only.yml @@ -7,6 +7,11 @@ on: - ee/maintained-apps/inputs/** - ee/maintained-apps/outputs/** - cmd/maintained-apps/validate/** + - .github/workflows/test-fma-windows-pr-only.yml + - .github/workflows/test-fma-windows-validate.yml + - .github/scripts/partition-fma-apps.sh + - .github/scripts/detect-new-fmas-in-pr.sh + - .github/scripts/filter-apps-json.sh workflow_dispatch: # Manual trigger inputs: log_level: @@ -20,20 +25,21 @@ on: - warn - error -env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - permissions: contents: read jobs: - test-fma-pr-only: - env: - LOG_LEVEL: ${{ github.event.inputs.log_level || 'info' }} - # ARM64 runner so we can validate ARM-native FMAs. x86/x64 FMAs continue to - # install and run here via Windows 11 on ARM's Prism emulation. - runs-on: windows-11-arm - + # Detect which apps changed and partition them by installer architecture on a + # cheap Linux runner. The (much more expensive) Windows runners below only + # spin up when there are Windows apps to validate, and each app is routed to + # a runner whose native architecture matches its installer: arm64 apps to + # windows-11-arm, x64/x86/neutral apps to the x64 runner. Large PRs are split + # into shards that validate in parallel. + detect-changed-apps: + runs-on: ubuntu-latest + outputs: + has_windows_apps: ${{ steps.partition.outputs.has_apps }} + matrix: ${{ steps.partition.outputs.matrix }} steps: - name: Harden Runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -43,720 +49,69 @@ jobs: - name: Checkout Fleet uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - repository: fleetdm/fleet fetch-depth: 0 # Need full history to compare with base branch - ref: ${{ github.ref }} - path: fleet persist-credentials: false - - name: Setup Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 - with: - go-version-file: "fleet/go.mod" - - - name: Setup Git for base branch comparison - run: | - cd fleet - git config --global --add safe.directory $PWD - shell: pwsh - - - name: Fetch base branch - run: | - cd fleet - $baseBranch = "${{ github.event.pull_request.base.ref || github.base_ref || 'main' }}" - Write-Host "Fetching base branch: $baseBranch" - git fetch origin "$baseBranch`:$baseBranch" || exit 0 - shell: pwsh - - name: Detect changed apps id: detect-changed env: GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }} run: | - cd fleet - $env:GITHUB_WORKSPACE = (Get-Location).Path + # fetch-depth 0 normally brings in the base branch already; fetch it + # explicitly as a fallback so origin/$GITHUB_BASE_REF exists. + git fetch origin "$GITHUB_BASE_REF" || true bash .github/scripts/detect-new-fmas-in-pr.sh - shell: pwsh - - name: Check if there are changes - id: check-changes + - name: Partition Windows apps by architecture + id: partition + env: + CHANGED_APPS: ${{ steps.detect-changed.outputs.CHANGED_APPS }} run: | - # Default to no changes if detection step failed or didn't set output - $hasChanges = "${{ steps.detect-changed.outputs.HAS_CHANGES }}" - if ($hasChanges -eq "true") { - "has_changes=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "Changed apps detected: ${{ steps.detect-changed.outputs.CHANGED_APPS }}" - } else { - "has_changes=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "No changed apps detected, skipping validation" - } - shell: pwsh + bash .github/scripts/partition-fma-apps.sh windows "${CHANGED_APPS:-[]}" 25 - - name: Check if there are Windows apps - id: check-windows-apps + validate: + needs: detect-changed-apps + if: needs.detect-changed-apps.outputs.has_windows_apps == 'true' + name: ${{ matrix.name }} + strategy: + # Don't cancel the other architecture's validation if one fails. + fail-fast: false + matrix: + include: ${{ fromJSON(needs.detect-changed-apps.outputs.matrix) }} + uses: ./.github/workflows/test-fma-windows-validate.yml + permissions: + contents: read + with: + runner: ${{ matrix.runner }} + slugs: ${{ matrix.slugs }} + log_level: ${{ github.event.inputs.log_level || 'info' }} + + # Stable-named summary check (matches the old single-job name) so PR gating + # doesn't depend on the dynamic per-architecture/per-shard matrix job names. + test-fma-pr-only: + needs: [detect-changed-apps, validate] + if: always() + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Check validation results + env: + DETECT_RESULT: ${{ needs.detect-changed-apps.result }} + VALIDATE_RESULT: ${{ needs.validate.result }} run: | - if ("${{ steps.check-changes.outputs.has_changes }}" -ne "true") { - "has_windows_apps=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_google_chrome=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_7zip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_firefox=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_nodejs=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_powershell=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_r=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_git=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - exit 0 - } - - # Filter changed apps to only include windows platform - $changedAppsJson = '${{ steps.detect-changed.outputs.CHANGED_APPS }}' - $windowsSlugs = ($changedAppsJson | ConvertFrom-Json | Where-Object { $_ -like "*/windows" }) - - if ($null -eq $windowsSlugs -or $windowsSlugs.Count -eq 0) { - "has_windows_apps=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_google_chrome=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_7zip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_firefox=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_nodejs=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_powershell=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_r=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "has_git=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "No windows apps changed, skipping Windows workflow" - } else { - "has_windows_apps=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "Windows apps detected:" - $windowsSlugs | ForEach-Object { Write-Host " - $_" } - - # Check if google-chrome/windows is in the changed apps - # Use -in operator which works for both arrays and single values - if ("google-chrome/windows" -in $windowsSlugs) { - "has_google_chrome=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "Google Chrome detected in changed apps" - } else { - "has_google_chrome=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - } - - # Check if 7-zip/windows is in the changed apps - if ("7-zip/windows" -in $windowsSlugs) { - "has_7zip=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "7-zip detected in changed apps" - } else { - "has_7zip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - } - - # Check if firefox/windows or firefox@esr/windows is in the changed apps - if (("firefox/windows" -in $windowsSlugs) -or ("firefox@esr/windows" -in $windowsSlugs)) { - "has_firefox=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "Firefox detected in changed apps" - } else { - "has_firefox=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - } - - # Check if nodejs/windows is in the changed apps - if ("nodejs/windows" -in $windowsSlugs) { - "has_nodejs=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "Node.js detected in changed apps" - } else { - "has_nodejs=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - } - - # Check if powershell/windows is in the changed apps - if ("powershell/windows" -in $windowsSlugs) { - "has_powershell=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "PowerShell detected in changed apps" - } else { - "has_powershell=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - } - - # Check if r/windows is in the changed apps - if ("r/windows" -in $windowsSlugs) { - "has_r=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "R detected in changed apps" - } else { - "has_r=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - } - - # Check if git/windows is in the changed apps - if ("git/windows" -in $windowsSlugs) { - "has_git=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "Git detected in changed apps" - } else { - "has_git=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - } - } - shell: pwsh - - - name: Install osquery windows - if: steps.check-windows-apps.outputs.has_windows_apps == 'true' - run: | - Write-Host "Runner architecture: $env:PROCESSOR_ARCHITECTURE" - # Use the native osquery build for the runner architecture. On - # windows-11-arm this picks the arm64 zip so osqueryi runs natively - # rather than under Prism emulation; x86_64 runners keep the x64 zip. - if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { - $osqueryAsset = "osquery-5.18.1.windows_arm64.zip" - } else { - $osqueryAsset = "osquery-5.18.1.windows_x86_64.zip" - } - Write-Host "Downloading osquery asset: $osqueryAsset" - curl -L -o osquery.zip "https://github.com/osquery/osquery/releases/download/5.18.1/$osqueryAsset" - Expand-Archive -Path osquery.zip -DestinationPath osquery - Get-ChildItem -Recurse osquery | Where-Object { $_.Name -like "*osquery*" -and $_.Extension -eq ".exe" } - $osqueryPath = (Get-ChildItem -Recurse osquery | Where-Object { $_.Name -eq "osqueryi.exe" }).Directory.FullName - echo "Adding to PATH: $osqueryPath" - echo $osqueryPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - shell: pwsh - - - name: Remove pre-installed google chrome - if: steps.check-windows-apps.outputs.has_windows_apps == 'true' && steps.check-windows-apps.outputs.has_google_chrome == 'true' - run: | - Write-Host "Listing all installed packages containing 'Chrome':" - Get-Package | Where-Object { $_.Name -like "*Chrome*" } | ForEach-Object { - Write-Host " - $($_.Name) (Version: $($_.Version))" - } - - $uninstallPath = Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" | Where-Object { $_.DisplayName -like "*Google Chrome*" } | Select-Object -ExpandProperty UninstallString - if ($uninstallPath) { - Write-Host "Found Chrome uninstall path: $uninstallPath" - try { - $guid = ($uninstallPath -split "/X")[1] - Write-Host "Uninstalling Chrome MSI with GUID: $guid" - Start-Process -FilePath "msiexec.exe" -ArgumentList "/X$guid", "/quiet", "/norestart" -Wait -NoNewWindow - Write-Host "Successfully removed Google Chrome via MSI uninstaller" - } catch { - Write-Host "Failed to remove Chrome: $($_.Exception.Message)" - } - } else { - Write-Host "Chrome uninstall path not found in registry" - } - shell: pwsh - - - name: Remove pre-installed 7-zip - if: steps.check-windows-apps.outputs.has_windows_apps == 'true' && steps.check-windows-apps.outputs.has_7zip == 'true' - run: | - Write-Host "Listing all installed packages containing '7-Zip':" - Get-Package | Where-Object { $_.Name -like "*7-Zip*" } | ForEach-Object { - Write-Host " - $($_.Name) (Version: $($_.Version))" - } - - # Check registry for 7-Zip uninstaller - $uninstallPaths = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) - - $found = $false - foreach ($path in $uninstallPaths) { - $uninstallEntry = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "*7-Zip*" -and $_.Publisher -like "*Igor Pavlov*" } - if ($uninstallEntry) { - $found = $true - Write-Host "Found 7-Zip uninstall entry: $($uninstallEntry.DisplayName)" - - # Try to get uninstall string - $uninstallString = if ($uninstallEntry.QuietUninstallString) { - $uninstallEntry.QuietUninstallString - } elseif ($uninstallEntry.UninstallString) { - $uninstallEntry.UninstallString - } else { - $null - } - - if ($uninstallString) { - Write-Host "Found 7-Zip uninstall path: $uninstallString" - try { - # Check if it's an MSI uninstall (contains /X or /I) - if ($uninstallString -match "/X\{([A-F0-9\-]+)\}") { - $guid = $matches[1] - Write-Host "Uninstalling 7-Zip MSI with GUID: $guid" - Start-Process -FilePath "msiexec.exe" -ArgumentList "/X{$guid}", "/quiet", "/norestart" -Wait -NoNewWindow - Write-Host "Successfully removed 7-Zip via MSI uninstaller" - } elseif ($uninstallString -match '"([^"]+)"') { - # Extract executable path - $exePath = $matches[1] - Write-Host "Uninstalling 7-Zip via executable: $exePath" - # 7-Zip typically uses /S for silent uninstall - Start-Process -FilePath $exePath -ArgumentList "/S" -Wait -NoNewWindow - Write-Host "Successfully removed 7-Zip via executable uninstaller" - } else { - Write-Host "Could not parse uninstall string format: $uninstallString" - } - } catch { - Write-Host "Failed to remove 7-Zip: $($_.Exception.Message)" - } - } else { - Write-Host "7-Zip uninstall string not found in registry entry" - } - break - } - } - - if (-not $found) { - Write-Host "7-Zip uninstall path not found in registry" - } - shell: pwsh - - - name: Remove pre-installed Firefox - if: steps.check-windows-apps.outputs.has_windows_apps == 'true' && steps.check-windows-apps.outputs.has_firefox == 'true' - run: | - Write-Host "Listing all installed packages containing 'Firefox':" - Get-Package | Where-Object { $_.Name -like "*Firefox*" } | ForEach-Object { - Write-Host " - $($_.Name) (Version: $($_.Version))" - } - - $uninstallPaths = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) - - $found = $false - foreach ($path in $uninstallPaths) { - $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "*Mozilla Firefox*" } - foreach ($entry in $entries) { - if (-not $entry) { continue } - $found = $true - Write-Host "Found Firefox: $($entry.DisplayName)" - - $uninstallString = if ($entry.QuietUninstallString) { - $entry.QuietUninstallString - } elseif ($entry.UninstallString) { - $entry.UninstallString - } else { - $null - } - - if ($uninstallString) { - Write-Host "Uninstall string: $uninstallString" - try { - $splitArgs = $uninstallString.Split('"') - if ($splitArgs.Length -ge 3) { - $exePath = $splitArgs[1] - Write-Host "Uninstalling Firefox via: $exePath /S" - Start-Process -FilePath $exePath -ArgumentList "/S" -Wait -NoNewWindow - Write-Host "Successfully removed $($entry.DisplayName)" - } else { - Write-Host "Uninstalling Firefox via: $uninstallString /S" - Start-Process -FilePath $uninstallString -ArgumentList "/S" -Wait -NoNewWindow - Write-Host "Successfully removed $($entry.DisplayName)" - } - } catch { - Write-Host "Failed to remove Firefox: $($_.Exception.Message)" - } - } else { - Write-Host "Firefox uninstall string not found in registry entry" - } - } - } - - if (-not $found) { - Write-Host "Firefox not found in registry" - } - - # Kill any lingering Firefox/Mozilla processes - Write-Host "Stopping any lingering Firefox processes..." - Get-Process -Name "firefox","plugin-container","updater","maintenanceservice*","helper" -ErrorAction SilentlyContinue | ForEach-Object { - Write-Host " Killing process: $($_.Name) (PID: $($_.Id))" - Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue - } - - Start-Sleep -Seconds 10 - - # Force-remove leftover Firefox directories from Program Files - $firefoxDirs = @( - "C:\Program Files\Mozilla Firefox", - "C:\Program Files (x86)\Mozilla Firefox", - "C:\Program Files\Mozilla Maintenance Service" - ) - foreach ($dir in $firefoxDirs) { - if (Test-Path $dir) { - Write-Host "Removing leftover directory: $dir" - Remove-Item -Path $dir -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path $dir) { - Write-Host "WARNING: Failed to fully remove $dir" - } else { - Write-Host "Removed $dir" - } - } - } - shell: pwsh - - - name: Remove pre-installed Node.js - if: steps.check-windows-apps.outputs.has_windows_apps == 'true' && steps.check-windows-apps.outputs.has_nodejs == 'true' - run: | - Write-Host "Listing all installed packages containing 'Node':" - Get-Package | Where-Object { $_.Name -like "*Node*" } | ForEach-Object { - Write-Host " - $($_.Name) (Version: $($_.Version))" - } - - # Node.js installs via MSI and registers under "Node.js" / "Node.js Foundation". - $uninstallPaths = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) - - $found = $false - foreach ($path in $uninstallPaths) { - $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "Node.js*" -and $_.Publisher -like "*Node.js Foundation*" } - foreach ($entry in $entries) { - if (-not $entry) { continue } - $found = $true - Write-Host "Found Node.js uninstall entry: $($entry.DisplayName) (Version: $($entry.DisplayVersion))" - - $uninstallString = if ($entry.QuietUninstallString) { - $entry.QuietUninstallString - } elseif ($entry.UninstallString) { - $entry.UninstallString - } else { - $null - } - - if ($uninstallString) { - Write-Host "Found Node.js uninstall path: $uninstallString" - try { - # Node.js uses an MSI uninstaller (MsiExec.exe /X{GUID} or /I{GUID}) - if ($uninstallString -match "/[XI]\{([A-F0-9\-]+)\}") { - $guid = $matches[1] - Write-Host "Uninstalling Node.js MSI with GUID: $guid" - Start-Process -FilePath "msiexec.exe" -ArgumentList "/X{$guid}", "/quiet", "/norestart" -Wait -NoNewWindow - Write-Host "Successfully removed Node.js via MSI uninstaller" - } else { - Write-Host "Could not parse uninstall string format: $uninstallString" - } - } catch { - Write-Host "Failed to remove Node.js: $($_.Exception.Message)" - } - } else { - Write-Host "Node.js uninstall string not found in registry entry" - } - } - } - - if (-not $found) { - Write-Host "Node.js uninstall path not found in registry" - } - - # Force-remove leftover Node.js directory in case files remain after MSI removal - $nodeDir = "C:\Program Files\nodejs" - if (Test-Path $nodeDir) { - Write-Host "Removing leftover directory: $nodeDir" - Remove-Item -Path $nodeDir -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path $nodeDir) { - Write-Host "WARNING: Failed to fully remove $nodeDir" - } else { - Write-Host "Removed $nodeDir" - } - } - shell: pwsh - - - name: Remove pre-installed PowerShell - if: steps.check-windows-apps.outputs.has_windows_apps == 'true' && steps.check-windows-apps.outputs.has_powershell == 'true' - # NOTE: this step (and the verify step below) run under Windows PowerShell 5.1 - # (shell: powershell), NOT pwsh. We are about to uninstall PowerShell 7, so we - # must not be executing inside pwsh.exe (it would be locked / unavailable). - run: | - Write-Host "Listing all installed packages containing 'PowerShell':" - Get-Package | Where-Object { $_.Name -like "*PowerShell*" } | ForEach-Object { - Write-Host " - $($_.Name) (Version: $($_.Version))" - } - - # PowerShell 7 installs via MSI and registers under "PowerShell 7-x64" / - # "Microsoft Corporation". GitHub-hosted windows runners ship with it - # pre-installed, which must be removed so the validator starts from a clean state. - $uninstallPaths = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) - - $found = $false - foreach ($path in $uninstallPaths) { - $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "PowerShell 7*" -and $_.Publisher -like "*Microsoft Corporation*" } - foreach ($entry in $entries) { - if (-not $entry) { continue } - $found = $true - Write-Host "Found PowerShell uninstall entry: $($entry.DisplayName) (Version: $($entry.DisplayVersion))" - - $uninstallString = if ($entry.QuietUninstallString) { - $entry.QuietUninstallString - } elseif ($entry.UninstallString) { - $entry.UninstallString - } else { - $null - } - - if ($uninstallString) { - Write-Host "Found PowerShell uninstall path: $uninstallString" - try { - # PowerShell 7 uses an MSI uninstaller (MsiExec.exe /X{GUID} or /I{GUID}) - if ($uninstallString -match "/[XI]\{([A-F0-9\-]+)\}") { - $guid = $matches[1] - Write-Host "Uninstalling PowerShell MSI with GUID: $guid" - Start-Process -FilePath "msiexec.exe" -ArgumentList "/X{$guid}", "/quiet", "/norestart" -Wait -NoNewWindow - Write-Host "Successfully removed PowerShell via MSI uninstaller" - } else { - Write-Host "Could not parse uninstall string format: $uninstallString" - } - } catch { - Write-Host "Failed to remove PowerShell: $($_.Exception.Message)" - } - } else { - Write-Host "PowerShell uninstall string not found in registry entry" - } - } - } - - if (-not $found) { - Write-Host "PowerShell uninstall path not found in registry" - } - - # Force-remove leftover PowerShell 7 directory in case files remain after MSI removal - $psDir = "C:\Program Files\PowerShell\7" - if (Test-Path $psDir) { - Write-Host "Removing leftover directory: $psDir" - Remove-Item -Path $psDir -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path $psDir) { - Write-Host "WARNING: Failed to fully remove $psDir" - } else { - Write-Host "Removed $psDir" - } - } - shell: powershell - - - name: Remove pre-installed R - if: steps.check-windows-apps.outputs.has_windows_apps == 'true' && steps.check-windows-apps.outputs.has_r == 'true' - run: | - Write-Host "Listing all installed packages containing 'R for Windows':" - Get-Package | Where-Object { $_.Name -like "*R for Windows*" } | ForEach-Object { - Write-Host " - $($_.Name) (Version: $($_.Version))" - } - - # Stop any R processes so the uninstaller doesn't fail on locked files - Get-Process -Name "Rgui","Rterm","Rscript" -ErrorAction SilentlyContinue | ForEach-Object { - Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue - } - - # R for Windows installs via Inno Setup and registers under "R for Windows " - # / "R Core Team". The version is embedded in the DisplayName, so match by prefix - # and use the registry UninstallString (Inno has no MSI ProductCode). - $uninstallPaths = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) - - $found = $false - foreach ($path in $uninstallPaths) { - $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "R for Windows*" -and $_.Publisher -like "*R Core Team*" } - foreach ($entry in $entries) { - if (-not $entry) { continue } - $found = $true - Write-Host "Found R uninstall entry: $($entry.DisplayName) (Version: $($entry.DisplayVersion))" - - $uninstallString = if ($entry.QuietUninstallString) { - $entry.QuietUninstallString - } elseif ($entry.UninstallString) { - $entry.UninstallString - } else { - $null - } - - if ($uninstallString) { - Write-Host "Found R uninstall path: $uninstallString" - try { - # R uses an Inno Setup uninstaller (unins000.exe). Parse the exe path - # (quoted or unquoted) and run it with silent Inno switches. - $exePath = "" - if ($uninstallString -match '^\s*"([^"]+)"') { - $exePath = $matches[1] - } elseif ($uninstallString -match '(?i)^\s*(.+?\.exe)') { - $exePath = $matches[1] - } - if ($exePath) { - Write-Host "Uninstalling R via: $exePath" - Start-Process -FilePath $exePath -ArgumentList "/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART" -Wait -NoNewWindow - Write-Host "Successfully removed R via Inno uninstaller" - } else { - Write-Host "Could not parse uninstall string format: $uninstallString" - } - } catch { - Write-Host "Failed to remove R: $($_.Exception.Message)" - } - } else { - Write-Host "R uninstall string not found in registry entry" - } - } - } - - if (-not $found) { - Write-Host "R uninstall path not found in registry" - } - - # Force-remove leftover R directory in case files remain after uninstall - $rDir = "C:\Program Files\R" - if (Test-Path $rDir) { - Write-Host "Removing leftover directory: $rDir" - Remove-Item -Path $rDir -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path $rDir) { - Write-Host "WARNING: Failed to fully remove $rDir" - } else { - Write-Host "Removed $rDir" - } - } - # Use Windows PowerShell 5.1 (not pwsh): the "Remove pre-installed PowerShell" - # step above may have uninstalled PowerShell 7, so pwsh.exe may be unavailable. - shell: powershell - - # NOTE: filtering is split out from validation and runs BEFORE "Remove pre-installed - # Git" below. Git for Windows provides the Git Bash 'bash' that this step's - # filter-apps-json.sh call depends on; validation itself does not need bash. - - name: Filter apps.json for changed apps - if: steps.check-windows-apps.outputs.has_windows_apps == 'true' - run: | - cd fleet - # Set GITHUB_WORKSPACE to current directory so scripts can find files - $env:GITHUB_WORKSPACE = (Get-Location).Path - - # Filter changed apps to only include windows platform - $changedAppsJson = '${{ steps.detect-changed.outputs.CHANGED_APPS }}' - $windowsSlugs = ($changedAppsJson | ConvertFrom-Json | Where-Object { $_ -like "*/windows" }) - - # Build a JSON array of the windows slugs. Construct it explicitly so a single - # slug still serializes as an array (Windows PowerShell unwraps single-element - # arrays via ConvertTo-Json), and write it to a BOM-free file. We then pass the - # file PATH -- not the JSON string -- to the bash script: forwarding a quoted - # JSON string across the PowerShell -> bash argument boundary mangles the - # embedded quotes under Windows PowerShell 5.1, which breaks jq --argjson. - $slugsArray = @($windowsSlugs) - $windowsSlugsJson = "[" + (($slugsArray | ForEach-Object { $_ | ConvertTo-Json -Compress }) -join ",") + "]" - Write-Host "Filtering apps.json for slugs: $windowsSlugsJson" - - $windowsSlugsFile = Join-Path $env:TEMP "windows-slugs-$(New-Guid).json" - Set-Content -Path $windowsSlugsFile -Value $windowsSlugsJson -Encoding ascii -NoNewline - # Use forward slashes so Git Bash reads the path reliably (it reads this arg as a file). - $windowsSlugsFileForBash = $windowsSlugsFile -replace '\\', '/' - - # Backup original apps.json - Copy-Item -Path "ee\maintained-apps\outputs\apps.json" -Destination "ee\maintained-apps\outputs\apps.json.backup" - - # Create filtered apps.json - # Use a fixed path for the temp file to avoid issues with bash - $filteredAppsJson = Join-Path $env:TEMP "filtered-apps-$(New-Guid).json" - bash .github/scripts/filter-apps-json.sh "$windowsSlugsFileForBash" "$filteredAppsJson" - if ($LASTEXITCODE -ne 0) { - Write-Host "Error: filter-apps-json.sh failed with exit code $LASTEXITCODE" + echo "detect-changed-apps: $DETECT_RESULT" + echo "validate: $VALIDATE_RESULT" + if [ "$DETECT_RESULT" != "success" ]; then + echo "Detecting changed apps failed" exit 1 - } - - # Verify the filtered file was created - if (-not (Test-Path $filteredAppsJson)) { - Write-Host "Error: Filtered apps.json was not created at $filteredAppsJson" + fi + # validate is skipped when the PR changes no Windows apps. + if [ "$VALIDATE_RESULT" != "success" ] && [ "$VALIDATE_RESULT" != "skipped" ]; then + echo "Validation failed" exit 1 - } - - # Replace apps.json with filtered version - Move-Item -Path $filteredAppsJson -Destination "ee\maintained-apps\outputs\apps.json" -Force - # Use Windows PowerShell 5.1 (not pwsh): the "Remove pre-installed PowerShell" - # step above may have uninstalled PowerShell 7, so pwsh.exe may be unavailable. - shell: powershell - - - name: Remove pre-installed Git - if: steps.check-windows-apps.outputs.has_windows_apps == 'true' && steps.check-windows-apps.outputs.has_git == 'true' - # IMPORTANT: this MUST run AFTER "Filter apps.json for changed apps" (which uses Git - # Bash) and BEFORE "Validate changed apps". Git for Windows provides the 'bash' the - # filter step relies on; validation runs 'go run -buildvcs=false' and needs no bash. - run: | - Write-Host "Listing all installed packages containing 'Git':" - Get-Package | Where-Object { $_.Name -like "*Git*" } | ForEach-Object { - Write-Host " - $($_.Name) (Version: $($_.Version))" - } - - # Stop Git-related processes so the uninstaller doesn't fail on locked files - Get-Process -Name "git","bash","sh","ssh-agent","gitk","wish" -ErrorAction SilentlyContinue | ForEach-Object { - Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue - } - - # Git for Windows installs via Inno Setup. Its registry DisplayName is not - # reliably "Git version " (the runner's pre-installed Git is listed as - # just "Git"), so anchor on the publisher -- which is unique to Git for - # Windows -- and loosely guard the DisplayName. Use the registry - # UninstallString (Inno has no MSI ProductCode). - $uninstallPaths = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) - - $found = $false - foreach ($path in $uninstallPaths) { - $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "Git*" -and $_.Publisher -like "*The Git Development Community*" } - foreach ($entry in $entries) { - if (-not $entry) { continue } - $found = $true - Write-Host "Found Git uninstall entry: $($entry.DisplayName) (Version: $($entry.DisplayVersion))" - - $uninstallString = if ($entry.QuietUninstallString) { - $entry.QuietUninstallString - } elseif ($entry.UninstallString) { - $entry.UninstallString - } else { - $null - } - - if ($uninstallString) { - Write-Host "Found Git uninstall path: $uninstallString" - try { - # Git for Windows uses an Inno Setup uninstaller (unins000.exe). Parse the - # exe path (quoted or unquoted) and run it with silent Inno switches. - $exePath = "" - if ($uninstallString -match '^\s*"([^"]+)"') { - $exePath = $matches[1] - } elseif ($uninstallString -match '(?i)^\s*(.+?\.exe)') { - $exePath = $matches[1] - } - if ($exePath) { - Write-Host "Uninstalling Git via: $exePath" - Start-Process -FilePath $exePath -ArgumentList "/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART" -Wait -NoNewWindow - Write-Host "Successfully removed Git via Inno uninstaller" - } else { - Write-Host "Could not parse uninstall string format: $uninstallString" - } - } catch { - Write-Host "Failed to remove Git: $($_.Exception.Message)" - } - } else { - Write-Host "Git uninstall string not found in registry entry" - } - } - } - - if (-not $found) { - Write-Host "Git uninstall path not found in registry" - } - - # Force-remove leftover Git directory in case files remain after uninstall - $gitDir = "C:\Program Files\Git" - if (Test-Path $gitDir) { - Write-Host "Removing leftover directory: $gitDir" - Remove-Item -Path $gitDir -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path $gitDir) { - Write-Host "WARNING: Failed to fully remove $gitDir" - } else { - Write-Host "Removed $gitDir" - } - } - # Use Windows PowerShell 5.1 (not pwsh): the "Remove pre-installed PowerShell" - # step above may have uninstalled PowerShell 7, so pwsh.exe may be unavailable. - shell: powershell - - - name: Validate changed apps - if: steps.check-windows-apps.outputs.has_windows_apps == 'true' - # -buildvcs=false so 'go run' does not invoke git for VCS stamping: the - # "Remove pre-installed Git" step above may have removed git from the runner. - run: | - cd fleet - $env:GITHUB_WORKSPACE = (Get-Location).Path - - # Run validation - ls "C:\Program Files" - go run -buildvcs=false ./cmd/maintained-apps/validate - - # Restore original apps.json - Move-Item -Path "ee\maintained-apps\outputs\apps.json.backup" -Destination "ee\maintained-apps\outputs\apps.json" -Force - # Use Windows PowerShell 5.1 (not pwsh): when validating the PowerShell FMA we - # uninstall PowerShell 7 in the step above, so pwsh.exe may not be available here. - shell: powershell + fi + echo "All Windows FMA validations passed" diff --git a/.github/workflows/test-fma-windows-validate.yml b/.github/workflows/test-fma-windows-validate.yml new file mode 100644 index 0000000000..d5bff362d9 --- /dev/null +++ b/.github/workflows/test-fma-windows-validate.yml @@ -0,0 +1,657 @@ +# Reusable workflow that installs and validates a set of Windows Fleet-maintained +# apps on a runner whose native architecture matches the apps' installer +# architecture (arm64 apps on windows-11-arm, x64/x86/neutral apps on the x64 +# runner). Called by test-fma-windows-pr-only.yml and test-fma-windows.yml with +# a matrix produced by .github/scripts/partition-fma-apps.sh. +name: Validate Fleet Maintained Apps - Windows + +on: + workflow_call: + inputs: + runner: + description: 'Runner label matching the apps'' installer architecture (e.g. "windows-latest" for x64/x86, "windows-11-arm" for arm64)' + required: true + type: string + slugs: + description: 'JSON array of app slugs to validate (e.g. ["7-zip/windows"])' + required: true + type: string + log_level: + description: "Log level (debug, info, warn, error)" + required: false + type: string + default: "info" + +env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LOG_LEVEL: ${{ inputs.log_level }} + +permissions: + contents: read + +jobs: + validate: + runs-on: ${{ inputs.runner }} + + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + # Changed-app detection and architecture partitioning happen in the + # calling workflow on a Linux runner, so no git history is needed here. + - name: Checkout Fleet + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + path: fleet + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: "fleet/go.mod" + + - name: Determine pre-installed apps to remove + id: check-windows-apps + # Pass the slugs through env rather than expanding ${{ inputs.slugs }} + # into the script body (flagged by zizmor as template injection). + env: + SLUGS_JSON: ${{ inputs.slugs }} + run: | + # SLUGS_JSON is a JSON array; wrap in @() so a single slug still + # behaves as an array. + $slugs = @($env:SLUGS_JSON | ConvertFrom-Json) + Write-Host "Apps to validate on this $env:PROCESSOR_ARCHITECTURE runner:" + $slugs | ForEach-Object { Write-Host " - $_" } + + # The runner images ship with some of the apps we validate already + # installed; flag the ones present in this shard so the removal steps + # below start the validator from a clean state. + $flags = [ordered]@{ + has_google_chrome = ("google-chrome/windows" -in $slugs) + has_7zip = ("7-zip/windows" -in $slugs) + has_firefox = (("firefox/windows" -in $slugs) -or ("firefox@esr/windows" -in $slugs)) + has_nodejs = ("nodejs/windows" -in $slugs) + has_powershell = ("powershell/windows" -in $slugs) + has_r = ("r/windows" -in $slugs) + has_git = ("git/windows" -in $slugs) + } + foreach ($key in $flags.Keys) { + "$key=$($flags[$key].ToString().ToLower())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + if ($flags[$key]) { Write-Host "$key detected in this shard" } + } + shell: pwsh + + - name: Install osquery windows + run: | + Write-Host "Runner architecture: $env:PROCESSOR_ARCHITECTURE" + # Use the native osquery build for the runner architecture. On + # windows-11-arm this picks the arm64 zip so osqueryi runs natively + # rather than under Prism emulation; x86_64 runners keep the x64 zip. + if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { + $osqueryAsset = "osquery-5.18.1.windows_arm64.zip" + } else { + $osqueryAsset = "osquery-5.18.1.windows_x86_64.zip" + } + Write-Host "Downloading osquery asset: $osqueryAsset" + curl -L -o osquery.zip "https://github.com/osquery/osquery/releases/download/5.18.1/$osqueryAsset" + Expand-Archive -Path osquery.zip -DestinationPath osquery + Get-ChildItem -Recurse osquery | Where-Object { $_.Name -like "*osquery*" -and $_.Extension -eq ".exe" } + $osqueryPath = (Get-ChildItem -Recurse osquery | Where-Object { $_.Name -eq "osqueryi.exe" }).Directory.FullName + echo "Adding to PATH: $osqueryPath" + echo $osqueryPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + shell: pwsh + + - name: Remove pre-installed google chrome + if: steps.check-windows-apps.outputs.has_google_chrome == 'true' + run: | + Write-Host "Listing all installed packages containing 'Chrome':" + Get-Package | Where-Object { $_.Name -like "*Chrome*" } | ForEach-Object { + Write-Host " - $($_.Name) (Version: $($_.Version))" + } + + $uninstallPath = Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" | Where-Object { $_.DisplayName -like "*Google Chrome*" } | Select-Object -ExpandProperty UninstallString + if ($uninstallPath) { + Write-Host "Found Chrome uninstall path: $uninstallPath" + try { + $guid = ($uninstallPath -split "/X")[1] + Write-Host "Uninstalling Chrome MSI with GUID: $guid" + Start-Process -FilePath "msiexec.exe" -ArgumentList "/X$guid", "/quiet", "/norestart" -Wait -NoNewWindow + Write-Host "Successfully removed Google Chrome via MSI uninstaller" + } catch { + Write-Host "Failed to remove Chrome: $($_.Exception.Message)" + } + } else { + Write-Host "Chrome uninstall path not found in registry" + } + shell: pwsh + + - name: Remove pre-installed 7-zip + if: steps.check-windows-apps.outputs.has_7zip == 'true' + run: | + Write-Host "Listing all installed packages containing '7-Zip':" + Get-Package | Where-Object { $_.Name -like "*7-Zip*" } | ForEach-Object { + Write-Host " - $($_.Name) (Version: $($_.Version))" + } + + # Check registry for 7-Zip uninstaller + $uninstallPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + $found = $false + foreach ($path in $uninstallPaths) { + $uninstallEntry = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "*7-Zip*" -and $_.Publisher -like "*Igor Pavlov*" } + if ($uninstallEntry) { + $found = $true + Write-Host "Found 7-Zip uninstall entry: $($uninstallEntry.DisplayName)" + + # Try to get uninstall string + $uninstallString = if ($uninstallEntry.QuietUninstallString) { + $uninstallEntry.QuietUninstallString + } elseif ($uninstallEntry.UninstallString) { + $uninstallEntry.UninstallString + } else { + $null + } + + if ($uninstallString) { + Write-Host "Found 7-Zip uninstall path: $uninstallString" + try { + # Check if it's an MSI uninstall (contains /X or /I) + if ($uninstallString -match "/X\{([A-F0-9\-]+)\}") { + $guid = $matches[1] + Write-Host "Uninstalling 7-Zip MSI with GUID: $guid" + Start-Process -FilePath "msiexec.exe" -ArgumentList "/X{$guid}", "/quiet", "/norestart" -Wait -NoNewWindow + Write-Host "Successfully removed 7-Zip via MSI uninstaller" + } elseif ($uninstallString -match '"([^"]+)"') { + # Extract executable path + $exePath = $matches[1] + Write-Host "Uninstalling 7-Zip via executable: $exePath" + # 7-Zip typically uses /S for silent uninstall + Start-Process -FilePath $exePath -ArgumentList "/S" -Wait -NoNewWindow + Write-Host "Successfully removed 7-Zip via executable uninstaller" + } else { + Write-Host "Could not parse uninstall string format: $uninstallString" + } + } catch { + Write-Host "Failed to remove 7-Zip: $($_.Exception.Message)" + } + } else { + Write-Host "7-Zip uninstall string not found in registry entry" + } + break + } + } + + if (-not $found) { + Write-Host "7-Zip uninstall path not found in registry" + } + shell: pwsh + + - name: Remove pre-installed Firefox + if: steps.check-windows-apps.outputs.has_firefox == 'true' + run: | + Write-Host "Listing all installed packages containing 'Firefox':" + Get-Package | Where-Object { $_.Name -like "*Firefox*" } | ForEach-Object { + Write-Host " - $($_.Name) (Version: $($_.Version))" + } + + $uninstallPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + $found = $false + foreach ($path in $uninstallPaths) { + $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "*Mozilla Firefox*" } + foreach ($entry in $entries) { + if (-not $entry) { continue } + $found = $true + Write-Host "Found Firefox: $($entry.DisplayName)" + + $uninstallString = if ($entry.QuietUninstallString) { + $entry.QuietUninstallString + } elseif ($entry.UninstallString) { + $entry.UninstallString + } else { + $null + } + + if ($uninstallString) { + Write-Host "Uninstall string: $uninstallString" + try { + $splitArgs = $uninstallString.Split('"') + if ($splitArgs.Length -ge 3) { + $exePath = $splitArgs[1] + Write-Host "Uninstalling Firefox via: $exePath /S" + Start-Process -FilePath $exePath -ArgumentList "/S" -Wait -NoNewWindow + Write-Host "Successfully removed $($entry.DisplayName)" + } else { + Write-Host "Uninstalling Firefox via: $uninstallString /S" + Start-Process -FilePath $uninstallString -ArgumentList "/S" -Wait -NoNewWindow + Write-Host "Successfully removed $($entry.DisplayName)" + } + } catch { + Write-Host "Failed to remove Firefox: $($_.Exception.Message)" + } + } else { + Write-Host "Firefox uninstall string not found in registry entry" + } + } + } + + if (-not $found) { + Write-Host "Firefox not found in registry" + } + + # Kill any lingering Firefox/Mozilla processes + Write-Host "Stopping any lingering Firefox processes..." + Get-Process -Name "firefox","plugin-container","updater","maintenanceservice*","helper" -ErrorAction SilentlyContinue | ForEach-Object { + Write-Host " Killing process: $($_.Name) (PID: $($_.Id))" + Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue + } + + Start-Sleep -Seconds 10 + + # Force-remove leftover Firefox directories from Program Files + $firefoxDirs = @( + "C:\Program Files\Mozilla Firefox", + "C:\Program Files (x86)\Mozilla Firefox", + "C:\Program Files\Mozilla Maintenance Service" + ) + foreach ($dir in $firefoxDirs) { + if (Test-Path $dir) { + Write-Host "Removing leftover directory: $dir" + Remove-Item -Path $dir -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path $dir) { + Write-Host "WARNING: Failed to fully remove $dir" + } else { + Write-Host "Removed $dir" + } + } + } + shell: pwsh + + - name: Remove pre-installed Node.js + if: steps.check-windows-apps.outputs.has_nodejs == 'true' + run: | + Write-Host "Listing all installed packages containing 'Node':" + Get-Package | Where-Object { $_.Name -like "*Node*" } | ForEach-Object { + Write-Host " - $($_.Name) (Version: $($_.Version))" + } + + # Node.js installs via MSI and registers under "Node.js" / "Node.js Foundation". + $uninstallPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + $found = $false + foreach ($path in $uninstallPaths) { + $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "Node.js*" -and $_.Publisher -like "*Node.js Foundation*" } + foreach ($entry in $entries) { + if (-not $entry) { continue } + $found = $true + Write-Host "Found Node.js uninstall entry: $($entry.DisplayName) (Version: $($entry.DisplayVersion))" + + $uninstallString = if ($entry.QuietUninstallString) { + $entry.QuietUninstallString + } elseif ($entry.UninstallString) { + $entry.UninstallString + } else { + $null + } + + if ($uninstallString) { + Write-Host "Found Node.js uninstall path: $uninstallString" + try { + # Node.js uses an MSI uninstaller (MsiExec.exe /X{GUID} or /I{GUID}) + if ($uninstallString -match "/[XI]\{([A-F0-9\-]+)\}") { + $guid = $matches[1] + Write-Host "Uninstalling Node.js MSI with GUID: $guid" + Start-Process -FilePath "msiexec.exe" -ArgumentList "/X{$guid}", "/quiet", "/norestart" -Wait -NoNewWindow + Write-Host "Successfully removed Node.js via MSI uninstaller" + } else { + Write-Host "Could not parse uninstall string format: $uninstallString" + } + } catch { + Write-Host "Failed to remove Node.js: $($_.Exception.Message)" + } + } else { + Write-Host "Node.js uninstall string not found in registry entry" + } + } + } + + if (-not $found) { + Write-Host "Node.js uninstall path not found in registry" + } + + # Force-remove leftover Node.js directory in case files remain after MSI removal + $nodeDir = "C:\Program Files\nodejs" + if (Test-Path $nodeDir) { + Write-Host "Removing leftover directory: $nodeDir" + Remove-Item -Path $nodeDir -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path $nodeDir) { + Write-Host "WARNING: Failed to fully remove $nodeDir" + } else { + Write-Host "Removed $nodeDir" + } + } + shell: pwsh + + - name: Remove pre-installed PowerShell + if: steps.check-windows-apps.outputs.has_powershell == 'true' + # NOTE: this step (and the steps below) run under Windows PowerShell 5.1 + # (shell: powershell), NOT pwsh. We are about to uninstall PowerShell 7, so we + # must not be executing inside pwsh.exe (it would be locked / unavailable). + run: | + Write-Host "Listing all installed packages containing 'PowerShell':" + Get-Package | Where-Object { $_.Name -like "*PowerShell*" } | ForEach-Object { + Write-Host " - $($_.Name) (Version: $($_.Version))" + } + + # PowerShell 7 installs via MSI and registers under "PowerShell 7-x64" / + # "Microsoft Corporation". GitHub-hosted windows runners ship with it + # pre-installed, which must be removed so the validator starts from a clean state. + $uninstallPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + $found = $false + foreach ($path in $uninstallPaths) { + $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "PowerShell 7*" -and $_.Publisher -like "*Microsoft Corporation*" } + foreach ($entry in $entries) { + if (-not $entry) { continue } + $found = $true + Write-Host "Found PowerShell uninstall entry: $($entry.DisplayName) (Version: $($entry.DisplayVersion))" + + $uninstallString = if ($entry.QuietUninstallString) { + $entry.QuietUninstallString + } elseif ($entry.UninstallString) { + $entry.UninstallString + } else { + $null + } + + if ($uninstallString) { + Write-Host "Found PowerShell uninstall path: $uninstallString" + try { + # PowerShell 7 uses an MSI uninstaller (MsiExec.exe /X{GUID} or /I{GUID}) + if ($uninstallString -match "/[XI]\{([A-F0-9\-]+)\}") { + $guid = $matches[1] + Write-Host "Uninstalling PowerShell MSI with GUID: $guid" + Start-Process -FilePath "msiexec.exe" -ArgumentList "/X{$guid}", "/quiet", "/norestart" -Wait -NoNewWindow + Write-Host "Successfully removed PowerShell via MSI uninstaller" + } else { + Write-Host "Could not parse uninstall string format: $uninstallString" + } + } catch { + Write-Host "Failed to remove PowerShell: $($_.Exception.Message)" + } + } else { + Write-Host "PowerShell uninstall string not found in registry entry" + } + } + } + + if (-not $found) { + Write-Host "PowerShell uninstall path not found in registry" + } + + # Force-remove leftover PowerShell 7 directory in case files remain after MSI removal + $psDir = "C:\Program Files\PowerShell\7" + if (Test-Path $psDir) { + Write-Host "Removing leftover directory: $psDir" + Remove-Item -Path $psDir -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path $psDir) { + Write-Host "WARNING: Failed to fully remove $psDir" + } else { + Write-Host "Removed $psDir" + } + } + shell: powershell + + - name: Remove pre-installed R + if: steps.check-windows-apps.outputs.has_r == 'true' + run: | + Write-Host "Listing all installed packages containing 'R for Windows':" + Get-Package | Where-Object { $_.Name -like "*R for Windows*" } | ForEach-Object { + Write-Host " - $($_.Name) (Version: $($_.Version))" + } + + # Stop any R processes so the uninstaller doesn't fail on locked files + Get-Process -Name "Rgui","Rterm","Rscript" -ErrorAction SilentlyContinue | ForEach-Object { + Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue + } + + # R for Windows installs via Inno Setup and registers under "R for Windows " + # / "R Core Team". The version is embedded in the DisplayName, so match by prefix + # and use the registry UninstallString (Inno has no MSI ProductCode). + $uninstallPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + $found = $false + foreach ($path in $uninstallPaths) { + $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "R for Windows*" -and $_.Publisher -like "*R Core Team*" } + foreach ($entry in $entries) { + if (-not $entry) { continue } + $found = $true + Write-Host "Found R uninstall entry: $($entry.DisplayName) (Version: $($entry.DisplayVersion))" + + $uninstallString = if ($entry.QuietUninstallString) { + $entry.QuietUninstallString + } elseif ($entry.UninstallString) { + $entry.UninstallString + } else { + $null + } + + if ($uninstallString) { + Write-Host "Found R uninstall path: $uninstallString" + try { + # R uses an Inno Setup uninstaller (unins000.exe). Parse the exe path + # (quoted or unquoted) and run it with silent Inno switches. + $exePath = "" + if ($uninstallString -match '^\s*"([^"]+)"') { + $exePath = $matches[1] + } elseif ($uninstallString -match '(?i)^\s*(.+?\.exe)') { + $exePath = $matches[1] + } + if ($exePath) { + Write-Host "Uninstalling R via: $exePath" + Start-Process -FilePath $exePath -ArgumentList "/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART" -Wait -NoNewWindow + Write-Host "Successfully removed R via Inno uninstaller" + } else { + Write-Host "Could not parse uninstall string format: $uninstallString" + } + } catch { + Write-Host "Failed to remove R: $($_.Exception.Message)" + } + } else { + Write-Host "R uninstall string not found in registry entry" + } + } + } + + if (-not $found) { + Write-Host "R uninstall path not found in registry" + } + + # Force-remove leftover R directory in case files remain after uninstall + $rDir = "C:\Program Files\R" + if (Test-Path $rDir) { + Write-Host "Removing leftover directory: $rDir" + Remove-Item -Path $rDir -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path $rDir) { + Write-Host "WARNING: Failed to fully remove $rDir" + } else { + Write-Host "Removed $rDir" + } + } + # Use Windows PowerShell 5.1 (not pwsh): the "Remove pre-installed PowerShell" + # step above may have uninstalled PowerShell 7, so pwsh.exe may be unavailable. + shell: powershell + + # NOTE: filtering is split out from validation and runs BEFORE "Remove pre-installed + # Git" below. Git for Windows provides the Git Bash 'bash' that this step's + # filter-apps-json.sh call depends on; validation itself does not need bash. + - name: Filter apps.json to this shard's apps + # Pass the slugs through env rather than expanding ${{ inputs.slugs }} + # into the script body (flagged by zizmor as template injection). + env: + SLUGS_JSON: ${{ inputs.slugs }} + run: | + cd fleet + # Set GITHUB_WORKSPACE to current directory so scripts can find files + $env:GITHUB_WORKSPACE = (Get-Location).Path + + # The shard's slugs arrive as a compact JSON array string built by the + # partition script, so no re-serialization is needed. Write it to a + # BOM-free file and pass the file PATH -- not the JSON string -- to the + # bash script: forwarding a quoted JSON string across the + # PowerShell -> bash argument boundary mangles the embedded quotes under + # Windows PowerShell 5.1, which breaks jq --argjson. + $windowsSlugsJson = $env:SLUGS_JSON + Write-Host "Filtering apps.json for slugs: $windowsSlugsJson" + + $windowsSlugsFile = Join-Path $env:TEMP "windows-slugs-$(New-Guid).json" + Set-Content -Path $windowsSlugsFile -Value $windowsSlugsJson -Encoding ascii -NoNewline + # Use forward slashes so Git Bash reads the path reliably (it reads this arg as a file). + $windowsSlugsFileForBash = $windowsSlugsFile -replace '\\', '/' + + # Backup original apps.json + Copy-Item -Path "ee\maintained-apps\outputs\apps.json" -Destination "ee\maintained-apps\outputs\apps.json.backup" + + # Create filtered apps.json + # Use a fixed path for the temp file to avoid issues with bash + $filteredAppsJson = Join-Path $env:TEMP "filtered-apps-$(New-Guid).json" + bash .github/scripts/filter-apps-json.sh "$windowsSlugsFileForBash" "$filteredAppsJson" + if ($LASTEXITCODE -ne 0) { + Write-Host "Error: filter-apps-json.sh failed with exit code $LASTEXITCODE" + exit 1 + } + + # Verify the filtered file was created + if (-not (Test-Path $filteredAppsJson)) { + Write-Host "Error: Filtered apps.json was not created at $filteredAppsJson" + exit 1 + } + + # Replace apps.json with filtered version + Move-Item -Path $filteredAppsJson -Destination "ee\maintained-apps\outputs\apps.json" -Force + # Use Windows PowerShell 5.1 (not pwsh): the "Remove pre-installed PowerShell" + # step above may have uninstalled PowerShell 7, so pwsh.exe may be unavailable. + shell: powershell + + - name: Remove pre-installed Git + if: steps.check-windows-apps.outputs.has_git == 'true' + # IMPORTANT: this MUST run AFTER "Filter apps.json to this shard's apps" (which uses + # Git Bash) and BEFORE "Validate apps". Git for Windows provides the 'bash' the + # filter step relies on; validation runs 'go run -buildvcs=false' and needs no bash. + run: | + Write-Host "Listing all installed packages containing 'Git':" + Get-Package | Where-Object { $_.Name -like "*Git*" } | ForEach-Object { + Write-Host " - $($_.Name) (Version: $($_.Version))" + } + + # Stop Git-related processes so the uninstaller doesn't fail on locked files + Get-Process -Name "git","bash","sh","ssh-agent","gitk","wish" -ErrorAction SilentlyContinue | ForEach-Object { + Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue + } + + # Git for Windows installs via Inno Setup. Its registry DisplayName is not + # reliably "Git version " (the runner's pre-installed Git is listed as + # just "Git"), so anchor on the publisher -- which is unique to Git for + # Windows -- and loosely guard the DisplayName. Use the registry + # UninstallString (Inno has no MSI ProductCode). + $uninstallPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + $found = $false + foreach ($path in $uninstallPaths) { + $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "Git*" -and $_.Publisher -like "*The Git Development Community*" } + foreach ($entry in $entries) { + if (-not $entry) { continue } + $found = $true + Write-Host "Found Git uninstall entry: $($entry.DisplayName) (Version: $($entry.DisplayVersion))" + + $uninstallString = if ($entry.QuietUninstallString) { + $entry.QuietUninstallString + } elseif ($entry.UninstallString) { + $entry.UninstallString + } else { + $null + } + + if ($uninstallString) { + Write-Host "Found Git uninstall path: $uninstallString" + try { + # Git for Windows uses an Inno Setup uninstaller (unins000.exe). Parse the + # exe path (quoted or unquoted) and run it with silent Inno switches. + $exePath = "" + if ($uninstallString -match '^\s*"([^"]+)"') { + $exePath = $matches[1] + } elseif ($uninstallString -match '(?i)^\s*(.+?\.exe)') { + $exePath = $matches[1] + } + if ($exePath) { + Write-Host "Uninstalling Git via: $exePath" + Start-Process -FilePath $exePath -ArgumentList "/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART" -Wait -NoNewWindow + Write-Host "Successfully removed Git via Inno uninstaller" + } else { + Write-Host "Could not parse uninstall string format: $uninstallString" + } + } catch { + Write-Host "Failed to remove Git: $($_.Exception.Message)" + } + } else { + Write-Host "Git uninstall string not found in registry entry" + } + } + } + + if (-not $found) { + Write-Host "Git uninstall path not found in registry" + } + + # Force-remove leftover Git directory in case files remain after uninstall + $gitDir = "C:\Program Files\Git" + if (Test-Path $gitDir) { + Write-Host "Removing leftover directory: $gitDir" + Remove-Item -Path $gitDir -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path $gitDir) { + Write-Host "WARNING: Failed to fully remove $gitDir" + } else { + Write-Host "Removed $gitDir" + } + } + # Use Windows PowerShell 5.1 (not pwsh): the "Remove pre-installed PowerShell" + # step above may have uninstalled PowerShell 7, so pwsh.exe may be unavailable. + shell: powershell + + - name: Validate apps + # -buildvcs=false so 'go run' does not invoke git for VCS stamping: the + # "Remove pre-installed Git" step above may have removed git from the runner. + run: | + cd fleet + $env:GITHUB_WORKSPACE = (Get-Location).Path + + # Run validation + ls "C:\Program Files" + go run -buildvcs=false ./cmd/maintained-apps/validate + + # Restore original apps.json + Move-Item -Path "ee\maintained-apps\outputs\apps.json.backup" -Destination "ee\maintained-apps\outputs\apps.json" -Force + # Use Windows PowerShell 5.1 (not pwsh): when validating the PowerShell FMA we + # uninstall PowerShell 7 in the step above, so pwsh.exe may not be available here. + shell: powershell diff --git a/.github/workflows/test-fma-windows.yml b/.github/workflows/test-fma-windows.yml index c891c8f492..dcd26ddaf3 100644 --- a/.github/workflows/test-fma-windows.yml +++ b/.github/workflows/test-fma-windows.yml @@ -15,21 +15,26 @@ on: - info - warn - error - -env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shard_size: + description: "Maximum number of apps per validation job (shards run in parallel)" + required: false + default: "20" + type: string permissions: contents: read jobs: - test-fma: - env: - LOG_LEVEL: ${{ github.event.inputs.log_level || 'info' }} - # ARM64 runner so we can validate ARM-native FMAs. x86/x64 FMAs continue to - # install and run here via Windows 11 on ARM's Prism emulation. - runs-on: windows-11-arm - + # Partition every Windows app in apps.json by installer architecture on a + # cheap Linux runner, then fan out to Windows runners whose native + # architecture matches each app's installer: arm64 apps to windows-11-arm, + # x64/x86/neutral apps to the x64 runner. Each architecture bucket is split + # into shards that validate in parallel. + partition: + runs-on: ubuntu-latest + outputs: + has_windows_apps: ${{ steps.partition.outputs.has_apps }} + matrix: ${{ steps.partition.outputs.matrix }} steps: - name: Harden Runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -39,63 +44,59 @@ jobs: - name: Checkout Fleet uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - repository: fleetdm/fleet fetch-depth: 1 - ref: ${{ github.ref }} - path: fleet persist-credentials: false - - name: Setup Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + - name: Partition all Windows apps by architecture + id: partition + env: + SHARD_SIZE: ${{ github.event.inputs.shard_size || '20' }} + run: | + ALL_WINDOWS_SLUGS=$(jq -c '[.apps[].slug | select(endswith("/windows"))]' ee/maintained-apps/outputs/apps.json) + bash .github/scripts/partition-fma-apps.sh windows "$ALL_WINDOWS_SLUGS" "$SHARD_SIZE" + + validate: + needs: partition + if: needs.partition.outputs.has_windows_apps == 'true' + name: ${{ matrix.name }} + strategy: + # Keep validating the remaining shards even if one fails so a full run + # reports every broken app, not just the first. + fail-fast: false + matrix: + include: ${{ fromJSON(needs.partition.outputs.matrix) }} + uses: ./.github/workflows/test-fma-windows-validate.yml + permissions: + contents: read + with: + runner: ${{ matrix.runner }} + slugs: ${{ matrix.slugs }} + log_level: ${{ github.event.inputs.log_level || 'info' }} + + # Stable-named summary job aggregating the per-architecture/per-shard results. + test-fma: + needs: [partition, validate] + if: always() + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: - go-version-file: "fleet/go.mod" + egress-policy: audit - - name: Install osquery windows + - name: Check validation results + env: + PARTITION_RESULT: ${{ needs.partition.result }} + VALIDATE_RESULT: ${{ needs.validate.result }} run: | - Write-Host "Runner architecture: $env:PROCESSOR_ARCHITECTURE" - # Use the native osquery build for the runner architecture. On - # windows-11-arm this picks the arm64 zip so osqueryi runs natively - # rather than under Prism emulation; x86_64 runners keep the x64 zip. - if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { - $osqueryAsset = "osquery-5.18.1.windows_arm64.zip" - } else { - $osqueryAsset = "osquery-5.18.1.windows_x86_64.zip" - } - Write-Host "Downloading osquery asset: $osqueryAsset" - curl -L -o osquery.zip "https://github.com/osquery/osquery/releases/download/5.18.1/$osqueryAsset" - Expand-Archive -Path osquery.zip -DestinationPath osquery - Get-ChildItem -Recurse osquery | Where-Object { $_.Name -like "*osquery*" -and $_.Extension -eq ".exe" } - $osqueryPath = (Get-ChildItem -Recurse osquery | Where-Object { $_.Name -eq "osqueryi.exe" }).Directory.FullName - echo "Adding to PATH: $osqueryPath" - echo $osqueryPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - shell: pwsh - - - name: Remove pre-installed google chrome - run: | - Write-Host "Listing all installed packages containing 'Chrome':" - Get-Package | Where-Object { $_.Name -like "*Chrome*" } | ForEach-Object { - Write-Host " - $($_.Name) (Version: $($_.Version))" - } - - $uninstallPath = Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" | Where-Object { $_.DisplayName -like "*Google Chrome*" } | Select-Object -ExpandProperty UninstallString - if ($uninstallPath) { - Write-Host "Found Chrome uninstall path: $uninstallPath" - try { - $guid = ($uninstallPath -split "/X")[1] - Write-Host "Uninstalling Chrome MSI with GUID: $guid" - Start-Process -FilePath "msiexec.exe" -ArgumentList "/X$guid", "/quiet", "/norestart" -Wait -NoNewWindow - Write-Host "Successfully removed Google Chrome via MSI uninstaller" - } catch { - Write-Host "Failed to remove Chrome: $($_.Exception.Message)" - } - } else { - Write-Host "Chrome uninstall path not found in registry" - } - shell: pwsh - - - name: Verify Fleet Maintained Apps windows - run: | - ls "C:\Program Files" - cd fleet - go run ./cmd/maintained-apps/validate - shell: pwsh + echo "partition: $PARTITION_RESULT" + echo "validate: $VALIDATE_RESULT" + if [ "$PARTITION_RESULT" != "success" ]; then + echo "Partitioning apps failed" + exit 1 + fi + if [ "$VALIDATE_RESULT" != "success" ] && [ "$VALIDATE_RESULT" != "skipped" ]; then + echo "Validation failed" + exit 1 + fi + echo "All Windows FMA validations passed"