name: Setup PMG
description: Install Package Manager Guard (PMG) and wrap npm/pip/etc. in this Linux GitHub Actions runner.
author: SafeDep
branding:
  icon: shield
  color: green

inputs:
  version:
    description: PMG release tag to install (e.g. v0.42.0) or "latest".
    required: false
    default: latest

  api-key:
    description: SafeDep Cloud API key. When set together with tenant-id, cloud sync is enabled and credentials are passed via env (no keychain needed in CI).
    required: false
    default: ""
  tenant-id:
    description: SafeDep Cloud tenant ID. Required when api-key is set.
    required: false
    default: ""
  endpoint-id:
    description: Identifier reported to SafeDep Cloud for this runner. When empty and cloud is enabled, defaults to "github-actions/<owner>/<repo>" so events aggregate per repository instead of per ephemeral runner hostname.
    required: false
    default: ""

  paranoid:
    description: Treat suspicious packages as malicious (PMG_PARANOID). Empty = use PMG default (false).
    required: false
    default: ""
  cooldown-enabled:
    description: Block recently-published package versions (PMG_DEPENDENCY_COOLDOWN_ENABLED). Empty = use PMG default (true).
    required: false
    default: ""
  cooldown-days:
    description: Cooldown window in days (PMG_DEPENDENCY_COOLDOWN_DAYS). Empty = use PMG default (5).
    required: false
    default: ""
  proxy-mode:
    description: Use proxy-based interception (PMG_PROXY_ENABLED). Empty = use PMG default (true). Setting "false" falls back to guard-based analysis.
    required: false
    default: ""

  sandbox:
    description: Run package managers inside an OS sandbox (PMG_SANDBOX_ENABLED). Empty = use PMG default (false). Enabling will also relax AppArmor user-namespace restrictions on the runner so unprivileged user namespaces work.
    required: false
    default: ""
  sandbox-driver:
    description: Sandbox backend on Linux (PMG_SANDBOX_DRIVER). One of "landlock" or "bubblewrap". Defaults to "landlock" when sandbox is enabled and this is empty.
    required: false
    default: ""

  verbosity:
    description: PMG output verbosity (PMG_VERBOSITY). One of "silent", "normal", "verbose". Empty = use PMG default.
    required: false
    default: ""
  disable-telemetry:
    description: Disable anonymous PMG telemetry (PMG_DISABLE_TELEMETRY). Empty = use PMG default.
    required: false
    default: ""
  skip-event-logging:
    description: Skip writing audit events to the local event log (PMG_SKIP_EVENT_LOGGING). Empty = use PMG default.
    required: false
    default: ""

  config-file:
    description: Path (relative to the workspace) to a user-supplied PMG config.yml. Copied into the PMG config directory before "pmg setup install" runs, so PMG merges any missing template keys into it.
    required: false
    default: ""

  cache:
    description: Reuse a previously-installed PMG binary from $RUNNER_TOOL_CACHE/pmg/<version>/<arch>. Off by default; fresh download per run keeps the binary current and reduces the blast radius of any cached artifact tampering. Checksum verification still runs on every install path.
    required: false
    default: "false"

  server-mode:
    description: Run PMG as a persistent proxy server instead of shims. The proxy is started as a daemon and HTTP_PROXY/cert env vars are exported to the job. Requires a job-end "pmg proxy stop --fail-on-violation" step.
    required: false
    default: "false"

runs:
  using: composite
  steps:
    - name: Verify runner OS
      shell: bash
      run: |
        if [ "${{ runner.os }}" != "Linux" ]; then
          echo "::error::safedep/pmg action currently supports Linux runners only. See https://github.com/safedep/pmg/issues/248 for cross-platform tracking."
          exit 1
        fi

    - name: Install PMG
      id: install
      shell: bash
      env:
        INPUT_VERSION: ${{ inputs.version }}
        INPUT_CACHE: ${{ inputs.cache }}
      run: |
        set -euo pipefail

        repo="safedep/pmg"

        # Resolve version tag.
        if [ "$INPUT_VERSION" = "latest" ] || [ -z "$INPUT_VERSION" ]; then
          echo "Resolving latest PMG release..."
          tag=$(curl -fsSI -o /dev/null -w '%{redirect_url}' \
            "https://github.com/${repo}/releases/latest" | sed 's|.*/||' | tr -d '\r\n')
          if [ -z "$tag" ]; then
            echo "::error::Could not resolve latest PMG release tag." >&2
            exit 1
          fi
        else
          tag="$INPUT_VERSION"
        fi
        echo "PMG version: $tag"

        # Map architecture.
        case "$(uname -m)" in
          x86_64|amd64) arch="x86_64" ;;
          aarch64|arm64) arch="arm64" ;;
          *)
            echo "::error::Unsupported architecture: $(uname -m)" >&2
            exit 1
            ;;
        esac

        asset="pmg_Linux_${arch}.tar.gz"
        cache_dir="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}/pmg/${tag}/${arch}"
        install_dir="$cache_dir"

        if [ "$INPUT_CACHE" != "true" ]; then
          install_dir="${RUNNER_TEMP:-/tmp}/pmg-${tag}-${arch}"
        fi

        mkdir -p "$install_dir"
        bin_path="${install_dir}/pmg"
        tar_path="${install_dir}/${asset}"

        # Always fetch upstream checksums.txt — even on a cache hit — so a
        # tampered cache entry is caught before we run the binary.
        tmpdir=$(mktemp -d)
        trap 'rm -rf "$tmpdir"' EXIT
        curl -fsSL -o "${tmpdir}/checksums.txt" \
          "https://github.com/${repo}/releases/download/${tag}/checksums.txt"
        expected=$(grep "  ${asset}\$" "${tmpdir}/checksums.txt" | cut -d' ' -f1 || true)
        if [ -z "$expected" ]; then
          echo "::error::No checksum entry for ${asset} in ${tag}/checksums.txt" >&2
          exit 1
        fi

        need_download=true
        if [ "$INPUT_CACHE" = "true" ] && [ -f "$tar_path" ] && [ -x "$bin_path" ]; then
          cached=$(sha256sum "$tar_path" | cut -d' ' -f1)
          if [ "$cached" = "$expected" ]; then
            echo "Cached PMG ${tag} verified against upstream checksum"
            need_download=false
          else
            echo "Cached PMG ${tag} checksum drift (want ${expected}, got ${cached}); refreshing"
            rm -f "$tar_path" "$bin_path"
          fi
        fi

        if [ "$need_download" = "true" ]; then
          curl -fsSL -o "$tar_path" \
            "https://github.com/${repo}/releases/download/${tag}/${asset}"
          actual=$(sha256sum "$tar_path" | cut -d' ' -f1)
          if [ "$actual" != "$expected" ]; then
            echo "::error::Checksum mismatch for ${asset}: want ${expected}, got ${actual}" >&2
            exit 1
          fi
          echo "Checksum verified for ${asset}"
          tar -xzf "$tar_path" -C "$install_dir" pmg
          chmod +x "$bin_path"
        fi

        echo "$install_dir" >> "$GITHUB_PATH"
        echo "pmg-bin-dir=$install_dir" >> "$GITHUB_OUTPUT"
        echo "pmg-version=$tag" >> "$GITHUB_OUTPUT"

    - name: Prepare sandbox (apt + AppArmor)
      if: inputs.sandbox == 'true'
      shell: bash
      env:
        INPUT_DRIVER: ${{ inputs.sandbox-driver }}
      run: |
        set -euo pipefail
        if [ "$INPUT_DRIVER" = "bubblewrap" ]; then
          if command -v sudo >/dev/null 2>&1; then
            sudo apt-get update
            sudo apt-get install -y bubblewrap
          else
            apt-get update
            apt-get install -y bubblewrap
          fi
        fi

        # Relax AppArmor restriction on unprivileged user namespaces so
        # Landlock helper and Bubblewrap can clone with CLONE_NEWUSER.
        # Tolerate failure on locked-down runners; the sandbox driver will
        # report a clear error at runtime if it cannot start.
        if command -v sudo >/dev/null 2>&1; then
          sudo systemctl stop apparmor 2>/dev/null || true
          sudo systemctl disable apparmor 2>/dev/null || true
          sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
        else
          systemctl stop apparmor 2>/dev/null || true
          systemctl disable apparmor 2>/dev/null || true
          sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
        fi

    - name: Stage user config file
      if: inputs.config-file != ''
      shell: bash
      env:
        INPUT_CONFIG_FILE: ${{ inputs.config-file }}
      run: |
        set -euo pipefail
        src="${GITHUB_WORKSPACE}/${INPUT_CONFIG_FILE}"
        if [ ! -f "$src" ]; then
          echo "::error::config-file not found: $src" >&2
          exit 1
        fi
        dest_dir="${XDG_CONFIG_HOME:-$HOME/.config}/safedep/pmg"
        mkdir -p "$dest_dir"
        cp "$src" "$dest_dir/config.yml"
        echo "Staged $src -> $dest_dir/config.yml"

    - name: Export PMG configuration
      shell: bash
      env:
        IN_API_KEY: ${{ inputs.api-key }}
        IN_TENANT_ID: ${{ inputs.tenant-id }}
        IN_ENDPOINT_ID: ${{ inputs.endpoint-id }}
        IN_PARANOID: ${{ inputs.paranoid }}
        IN_COOLDOWN_ENABLED: ${{ inputs.cooldown-enabled }}
        IN_COOLDOWN_DAYS: ${{ inputs.cooldown-days }}
        IN_PROXY_MODE: ${{ inputs.proxy-mode }}
        IN_SANDBOX: ${{ inputs.sandbox }}
        IN_SANDBOX_DRIVER: ${{ inputs.sandbox-driver }}
        IN_VERBOSITY: ${{ inputs.verbosity }}
        IN_DISABLE_TELEMETRY: ${{ inputs.disable-telemetry }}
        IN_SKIP_EVENT_LOGGING: ${{ inputs.skip-event-logging }}
        IN_SERVER_MODE: ${{ inputs.server-mode }}
        GH_REPOSITORY: ${{ github.repository }}
      run: |
        set -euo pipefail

        export_var() {
          local name="$1" value="$2"
          if [ -n "$value" ]; then
            printf '%s=%s\n' "$name" "$value" >> "$GITHUB_ENV"
          fi
        }

        # Cloud credentials. PMG sync reads these directly when no keychain
        # credential is found, so we skip "pmg cloud login" entirely.
        if [ -n "$IN_API_KEY" ] || [ -n "$IN_TENANT_ID" ]; then
          if [ -z "$IN_API_KEY" ] || [ -z "$IN_TENANT_ID" ]; then
            echo "::error::api-key and tenant-id must be set together." >&2
            exit 1
          fi
          echo "::add-mask::$IN_API_KEY"
          export_var SAFEDEP_API_KEY "$IN_API_KEY"
          export_var SAFEDEP_TENANT_ID "$IN_TENANT_ID"
          export_var PMG_CLOUD_ENABLED "true"

          if [ "$IN_SERVER_MODE" = "true" ]; then
            # In server mode the proxy daemon owns delivery and uses auto_sync
            # to gate both periodic sync and the shutdown flush.
            export_var PMG_CLOUD_AUTO_SYNC_ENABLED "true"
          else
            # PMG's opportunistic background auto-sync is designed for
            # long-lived workstations. On an ephemeral runner the detached child
            # can be torn down before it drains the WAL, and the WAL goes with
            # it. Turn it off and rely on an explicit `pmg cloud sync` at
            # job-end if the user wants events delivered.
            export_var PMG_CLOUD_AUTO_SYNC_ENABLED "false"
          fi

          if [ -n "$IN_ENDPOINT_ID" ]; then
            export_var PMG_CLOUD_ENDPOINT_ID "$IN_ENDPOINT_ID"
          else
            # Stable per-repo identifier; the runner hostname is ephemeral
            # so PMG's hostname fallback would create a new "machine" entry
            # in SafeDep Cloud for every job.
            export_var PMG_CLOUD_ENDPOINT_ID "gha/${GH_REPOSITORY}"
          fi
        fi

        # Only emit PMG_* vars when the user explicitly set the input. This
        # preserves the "config-file overrides PMG defaults" guarantee — env
        # vars take precedence over config.yml in Viper, so empty pass-through
        # means we don't shadow file-based tuning.
        export_var PMG_PARANOID                     "$IN_PARANOID"
        export_var PMG_DEPENDENCY_COOLDOWN_ENABLED  "$IN_COOLDOWN_ENABLED"
        export_var PMG_DEPENDENCY_COOLDOWN_DAYS     "$IN_COOLDOWN_DAYS"
        export_var PMG_PROXY_ENABLED                "$IN_PROXY_MODE"
        export_var PMG_SANDBOX_ENABLED              "$IN_SANDBOX"
        if [ "$IN_SANDBOX" = "true" ]; then
          driver="${IN_SANDBOX_DRIVER:-landlock}"
          export_var PMG_SANDBOX_DRIVER             "$driver"
        fi
        export_var PMG_VERBOSITY                    "$IN_VERBOSITY"
        export_var PMG_DISABLE_TELEMETRY            "$IN_DISABLE_TELEMETRY"
        export_var PMG_SKIP_EVENT_LOGGING           "$IN_SKIP_EVENT_LOGGING"

    - name: Run pmg setup install (shim mode)
      if: inputs.server-mode != 'true'
      shell: bash
      run: pmg setup install

    - name: Add PMG shims to PATH (shim mode)
      if: inputs.server-mode != 'true'
      shell: bash
      run: echo "$HOME/.pmg/bin" >> "$GITHUB_PATH"

    - name: Start PMG proxy server (server mode)
      if: inputs.server-mode == 'true'
      shell: bash
      run: |
        set -euo pipefail
        # No cert install needed: `pmg proxy env` exports the CA path via
        # NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/PIP_CERT, so package managers trust
        # the proxy without touching the OS trust store.
        pmg proxy start --daemon
        pmg proxy env >> "$GITHUB_ENV"

    - name: Verify PMG
      shell: bash
      run: |
        pmg version
        echo "npm resolves to: $(command -v npm || echo '<not installed>')"

outputs:
  version:
    description: The resolved PMG version that was installed.
    value: ${{ steps.install.outputs.pmg-version }}
  bin-dir:
    description: Directory containing the pmg binary on this runner.
    value: ${{ steps.install.outputs.pmg-bin-dir }}
