diff --git a/.github/scripts/affected-list.py b/.github/scripts/affected-list.py new file mode 100755 index 0000000..2123d61 --- /dev/null +++ b/.github/scripts/affected-list.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# Infer all affected features based on a list of changed features +# +# Usage in GitHub Actions: +# +# .github/scripts/affected-list.py 'changed_features_json' 'all_features_json' +# +# Usage in terminal: +# +# .github/scripts/affected-list.py '["macos-brew"]' "$(GITHUB_OUTPUT=/dev/null .github/scripts/all-features.sh)" + +import json +import os +import sys +from collections import defaultdict + + +# A helper function to recursively collect all dependents of a feature +def collect_dependents( + dependency_map: dict[str, set[str]], feature: str, visited: set[str] +) -> set[str]: + for dependent in dependency_map[feature]: + if dependent not in visited: + visited.add(dependent) + collect_dependents(dependency_map, dependent, visited) + return visited + + +# Build a dependency map from all features +# Format: {feature_a: [feature_b_depends_on_a, feature_c_depends_on_a, ...]} +def parse_dependencies(all_features: dict[str, list[str]]) -> dict[str, set[str]]: + dependency_map: dict[str, set[str]] = defaultdict(set) + + # Convert dependencies to a reverse map + for feature, dependencies in all_features.items(): + for dep in dependencies: + dependency_map[dep].add(feature) + + # Flatten trees of dependencies + # E.g., if A -> B and B -> C, then A -> B,C + flattened: dict[str, set[str]] = defaultdict(set) + for feature in all_features: + flattened[feature] = collect_dependents(dependency_map, feature, set()) + + return flattened + + +def infer_dependencies( + changed_features: list[str], dependency_map: dict[str, set[str]] +) -> list[str]: + affected_features: set[str] = set(changed_features) + for feature in changed_features: + affected_features.update(dependency_map[feature]) + return sorted(affected_features) + + +def output_to_github_actions(affected_features: list[str]) -> None: + github_output_path = os.getenv("GITHUB_OUTPUT") + if github_output_path: + with open(github_output_path, "a") as gh_out: + gh_out.write(f"features={json.dumps(affected_features)}\n") + + +def main() -> None: + # Parse input arguments + changed_features = list(json.loads(sys.argv[1])) + all_features = json.loads(sys.argv[2]) + + # Build the dependency map + dependency_map = parse_dependencies(all_features) + + # Infer affected features + affected_features = infer_dependencies(changed_features, dependency_map) + + print("Affected features:") + print(json.dumps(affected_features)) + + # Output to GitHub Actions + output_to_github_actions(affected_features) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/all-features.sh b/.github/scripts/all-features.sh new file mode 100755 index 0000000..5f4101f --- /dev/null +++ b/.github/scripts/all-features.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# .github/scripts/all-features.sh +# Outputs all features and their dependencies from Makefile + +set -euo pipefail + +# Extract features and their dependencies from Makefile +features_with_deps=$( + { make -prRn -f Makefile : 2>/dev/null || true; } | + awk ' + function trim(s) { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", s) + return s + } + function is_valid(name) { + return !(name == "" || name ~ /^\.|%/ || name ~ /^\//) + } + $0 == "# Files" { in_files = 1; next } + $0 ~ /^# files hash-table stats:/ { in_files = 0 } + !in_files { next } + $0 ~ /^# Not a target:/ { skip_next = 1; next } + /^[^#[:space:]].*:/ { + line = $0 + if (skip_next) { skip_next = 0; next } + split(line, parts, ":") + name = trim(parts[1]) + if (!is_valid(name)) { next } + deps = trim(substr(line, index(line, ":") + 1)) + gsub(/[[:space:]]+/, " ", deps) + if (deps == "") { + print name + next + } + print name " " deps + } + ' | + sort -u +) + +# Convert to JSON +features_json=$( + printf '%s\n' "$features_with_deps" | + jq -R -s -c ' + split("\n") + | map(select(length>0)) + | map( + split(" ") + | { (.[0]): (.[1:] | map(select(length>0))) } + ) + | add + ' +) + +echo "All features and dependencies:" >&2 +echo "$features_with_deps" >&2 + +# Output to GitHub Actions +echo "features=$features_json" >> "$GITHUB_OUTPUT" + +echo $features_json diff --git a/.github/scripts/changed-files.sh b/.github/scripts/changed-files.sh new file mode 100755 index 0000000..c679666 --- /dev/null +++ b/.github/scripts/changed-files.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# .github/scripts/changed-files.sh +# Outputs changed files between base and head commits for GitHub Actions + +set -euo pipefail + +# Read GitHub context from first argument +if [ $# -lt 1 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +GITHUB_CONTEXT="$1" + +# Parse event information using jq +event_name=$(echo "$GITHUB_CONTEXT" | jq -r '.event_name') + +# Determine base and head commits based on event type +if [ "$event_name" = "pull_request" ]; then + base=$(echo "$GITHUB_CONTEXT" | jq -r '.event.pull_request.base.sha') + head=$(echo "$GITHUB_CONTEXT" | jq -r '.event.pull_request.head.sha') +elif [ "$event_name" = "push" ]; then + base=$(echo "$GITHUB_CONTEXT" | jq -r '.event.before') + head=$(echo "$GITHUB_CONTEXT" | jq -r '.sha') +else + echo "Unsupported event: $event_name" >&2 + base="" + head="" +fi + +# Check if we have valid base and head commits +if [ -z "$base" ] || [ -z "$head" ] || [ "$base" = "null" ] || [ "$head" = "null" ]; then + echo "No base/head to diff; leaving paths empty." >&2 + echo "paths=" >> "$GITHUB_OUTPUT" + exit 0 +fi + +# Get changed files using git diff +files=$(git diff --name-only "$base" "$head" || true) + +echo "Changed files:" >&2 +echo "$files" >&2 + +# Output to GitHub Actions +{ + echo 'paths<> "$GITHUB_OUTPUT" diff --git a/.github/scripts/feature-list.sh b/.github/scripts/feature-list.sh new file mode 100755 index 0000000..cd989cb --- /dev/null +++ b/.github/scripts/feature-list.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# .github/scripts/feature-list.sh +# Generates feature list from changed file paths + +set -euo pipefail + +# Read changed paths from first argument +if [ $# -lt 1 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +changed_paths="$1" + +# Extract feature names for any changed file under pkg/some-feature/* +changed_features=$( + printf '%s\n' "$changed_paths" | + sed -n 's|^pkg/\([^/]*\).*|\1|p' | + sort -u +) + +echo "Changed features:" >&2 +echo "$changed_features" >&2 + +# Convert to JSON array +features_json=$( + printf '%s\n' "$changed_features" | + jq -R -s -c 'split("\n") | map(select(length>0))' +) + +# Output to GitHub Actions +echo "features=$features_json" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/merge-features.sh b/.github/scripts/merge-features.sh new file mode 100755 index 0000000..7271598 --- /dev/null +++ b/.github/scripts/merge-features.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# .github/scripts/merge-features.sh +# Merge passed in features with a list of fixed features + +set -euo pipefail + +FIXED_FEATURES=( + # ansible + # clean + # cli-network + # core + git + # javascript + # macos-brew + # macos-c-cpp + # macos-clean + # macos-cli-network + # macos-cli-useful + # macos-core + # macos-docker + # macos-duti + # macos-file-handler + # macos-finder + # macos-git + # macos-icon + # macos-javascript + # macos-markedit + # macos-micro + # macos-nano + # macos-popclip + # macos-quicklook + # macos-screensaver + # macos-shellcheck + # macos-stow + # macos-sublime + # macos-terminal + # macos-touch-id-sudo + # macos-vscode + # macos-zsh + # micro + # nano + # python + # stow + # zsh +) + +if [ $# -lt 1 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +features_json="$1" + +if [ -z "$features_json" ] || [ "$features_json" = "null" ]; then + features_json="[]" +fi + +if ! echo "$features_json" | jq -e '.' >/dev/null 2>&1; then + echo "Invalid JSON for features." >&2 + exit 1 +fi + +fixed_json=$( + printf '%s\n' "${FIXED_FEATURES[@]}" | + jq -R -s -c 'split("\n") | map(select(length>0))' +) + +merged_json=$( + echo "$features_json" | + jq -c --argjson fixed "$fixed_json" '. + $fixed | sort | unique' +) + +echo "Merged features:" >&2 +echo "$merged_json" | jq -r '.[]' >&2 + +echo "features=$merged_json" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/test_macos.yml b/.github/workflows/test_macos.yml index 6909c6f..2b7d2d8 100644 --- a/.github/workflows/test_macos.yml +++ b/.github/workflows/test_macos.yml @@ -6,71 +6,50 @@ on: - main paths-ignore: - "docs/**" + - "docs/**" pull_request: - schedule: - - cron: "0 2 * * *" jobs: - macOS: + generate-matrix: + name: 🌀 Generate matrix + runs-on: macos-latest + outputs: + features: ${{ steps.merge-features.outputs.features }} + steps: + - name: 🛒 Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 🗂️ Get all features + id: all-features + run: .github/scripts/all-features.sh + + - name: 📄 List changed files + id: changed-files + run: .github/scripts/changed-files.sh '${{ toJson(github) }}' + + - name: 🧮 Generate feature list + id: feature-list + run: .github/scripts/feature-list.sh '${{ steps.changed-files.outputs.paths }}' + + - name: 📊 Generate affected feature list + id: affected-list + run: .github/scripts/affected-list.py '${{ steps.feature-list.outputs.features }}' '${{ steps.all-features.outputs.features }}' + + # TODO: Remove this step when all features are moved under pkg/ + - name: 🔀 Merge with fixed features + id: merge-features + run: .github/scripts/merge-features.sh '${{ steps.affected-list.outputs.features }}' + + macos: + name: 🖥️ macOS Test runs-on: macos-latest + needs: generate-matrix strategy: fail-fast: false matrix: - features: [ - "ansible", - "clean", - "cli-network", - "core", - "git", - "javascript", - # "macos-alfred", # Script error to be fixed - # "macos-app-dev", # Depends on `git` - # "macos-app-private", # Depends on `git` - # "macos-app-work", # Depends on `git` - "macos-appcleaner", - "macos-brew", - "macos-c-cpp", - "macos-clean", - # "macos-cleanshot", # Script error to be fixed - "macos-cli-network", - "macos-cli-useful", - "macos-core", - "macos-docker", - "macos-dropbox", - "macos-duti", - "macos-file-handler", - "macos-finder", - "macos-font", - "macos-git", - "macos-hammerspoon", - "macos-icon", - "macos-iterm", - "macos-javascript", - "macos-markedit", - "macos-micro", - "macos-nano", - # "macos-one-password", # mas can't run without login on GitHub Actions - # "macos-perl", # Script error to be fixed - "macos-popclip", - "macos-quicklook", - "macos-screensaver", - "macos-service-workflow", - # "macos-settings", # Script error to be fixed - "macos-shellcheck", - "macos-stow", - "macos-sublime", - "macos-terminal", - "macos-touch-id-sudo", - "macos-tower", - "macos-vscode", - # "macos-xcode", # mas can't run without login on GitHub Actions - "macos-zsh", - "micro", - "nano", - "python", - "stow", - "zsh", - ] + features: ${{ fromJson(needs.generate-matrix.outputs.features) }} steps: - name: 🛒 Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/test_ubuntu.yml b/.github/workflows/test_ubuntu.yml index 70e5905..2c13232 100644 --- a/.github/workflows/test_ubuntu.yml +++ b/.github/workflows/test_ubuntu.yml @@ -5,10 +5,10 @@ on: branches: - main paths-ignore: - - 'docs/**' + - "docs/**" pull_request: schedule: - - cron: '0 2 * * *' + - cron: "0 2 * * *" jobs: Ubuntu: @@ -17,24 +17,23 @@ jobs: fail-fast: false matrix: os: [ubuntu-22.04, ubuntu-24.04, ubuntu-latest] - features: - [ - "ansible", - "clean", - "cli-network", + features: [ + # "ansible", + # "clean", + # "cli-network", "core", - "git", - "javascript", - "micro", - "nano", - "python", - "stow", - "ubuntu-dropbox", - "ubuntu-essential", - "ubuntu-locale-zhtw", - "ubuntu-ssh", - "ubuntu-tz-taipei", - "zsh", + # "git", + # "javascript", + # "micro", + # "nano", + # "python", + # "stow", + # "ubuntu-dropbox", + # "ubuntu-essential", + # "ubuntu-locale-zhtw", + # "ubuntu-ssh", + # "ubuntu-tz-taipei", + # "zsh", ] steps: - name: 🛒 Checkout diff --git a/Makefile b/Makefile index 01b5733..3d00b06 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ git: $(OS)-git micro: $(OS)-micro nano: $(OS)-nano stow: $(OS)-stow +vscode: $(OS)-vscode zsh: $(OS)-zsh ansible: stow diff --git a/makefiles/macos.mk b/makefiles/macos.mk index ce11272..267b357 100644 --- a/makefiles/macos.mk +++ b/makefiles/macos.mk @@ -153,7 +153,7 @@ macos-quicklook: macos-brew macos-screensaver: macos-brew run "pkg/macos-screensaver/macos-screensaver.sh" -macos-service-workflow: macos-stow +macos-services: macos-stow stow --dir 'pkg' --target "${HOME}/Library/Services" 'macos-services' macos-settings: diff --git a/makefiles/ubuntu.mk b/makefiles/ubuntu.mk index 05a892e..90a66cb 100644 --- a/makefiles/ubuntu.mk +++ b/makefiles/ubuntu.mk @@ -32,6 +32,9 @@ ubuntu-cli-network: ubuntu-dropbox: ubuntu-essential run "pkg/dropbox/ubuntu-dropbox.sh" +ubuntu-vscode: + echo "TODO" + ############################################################################### # CLIs # ############################################################################### diff --git a/pkg/macos-appcleaner/test b/pkg/macos-appcleaner/test new file mode 100644 index 0000000..e69de29 diff --git a/pkg/macos-xcode/macos-xcode.sh b/pkg/macos-xcode/macos-xcode.sh index 6647ef5..408028d 100755 --- a/pkg/macos-xcode/macos-xcode.sh +++ b/pkg/macos-xcode/macos-xcode.sh @@ -2,4 +2,4 @@ set -euo pipefail # Install Xcode -brew bundle --file pkg/xcode/macos-xcode.Brewfile +brew bundle --file pkg/macos-xcode/xcode.Brewfile