2026-05-18 00:44:06 +05:30
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 :
2026-07-22 15:19:19 +05:30
description : REMOVED. PMG always uses proxy-based interception. Setting "false" fails the action; any other value is ignored with a warning.
2026-05-18 00:44:06 +05:30
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"
2026-06-26 11:19:28 +05:30
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"
2026-05-18 00:44:06 +05:30
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 }}
2026-06-26 11:19:28 +05:30
IN_SERVER_MODE : ${{ inputs.server-mode }}
2026-05-18 00:44:06 +05:30
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
}
2026-07-22 15:19:19 +05:30
# Guard mode is removed: fail loudly when a workflow still opts out of
# proxy interception instead of silently switching it to proxy mode.
case "$(echo "$IN_PROXY_MODE" | tr '[:upper:]' '[:lower:]')" in
false|0|f)
echo "::error::PMG guard mode has been removed and proxy interception can no longer be disabled. Remove the 'proxy-mode' input." >&2
exit 1
;;
"") ;;
*)
echo "::warning::The 'proxy-mode' input is removed and has no effect. Remove it from your workflow." >&2
;;
esac
2026-05-18 00:44:06 +05:30
# 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"
2026-06-26 11:19:28 +05:30
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
2026-05-20 13:56:50 +05:30
2026-05-18 00:44:06 +05:30
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.
2026-06-26 14:47:53 +05:30
export_var PMG_CLOUD_ENDPOINT_ID "gha/${GH_REPOSITORY}"
2026-05-18 00:44:06 +05:30
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_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"
2026-06-26 11:19:28 +05:30
- name : Run pmg setup install (shim mode)
if : inputs.server-mode != 'true'
2026-05-18 00:44:06 +05:30
shell : bash
run : pmg setup install
2026-06-26 11:19:28 +05:30
- name : Add PMG shims to PATH (shim mode)
if : inputs.server-mode != 'true'
2026-05-18 00:44:06 +05:30
shell : bash
run : echo "$HOME/.pmg/bin" >> "$GITHUB_PATH"
2026-06-26 11:19:28 +05:30
- 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"
2026-05-18 00:44:06 +05:30
- 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 }}