Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,34 @@ jobs:
- name: Lint (${{ matrix.check }})
run: rite lint:${{ matrix.check }}

audit:
# Single-task jobs, one row per check on the PR.
checks:
strategy:
fail-fast: false
matrix:
check: ["format:check", typecheck, bandit, deptry, audit]
name: ${{ matrix.check }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v7

- uses: jdx/mise-action@v4

- name: Run ${{ matrix.check }}
run: "rite ${{ matrix.check }}"

gitleaks:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
with:
# gitleaks scans the full commit history
fetch-depth: 0

- uses: jdx/mise-action@v4

- name: Audit dependencies for known vulnerabilities
run: rite audit
- name: Scan git history for secrets
run: rite gitleaks

# "Does it build": render the formula against a tarball of HEAD, brew
# install it, run the formula test block, then a real `macprefs backup`
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/release-verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: release-verify

# Verifies the path real users take: install from the live tap
# (clintmod/homebrew-formulas) against the released GitHub tarball.
# Runs automatically when a release is published, with retries because
# the formula push and tarball can lag the release by a few minutes
# (this transient bit the v1.0.27 rollout). Also manually dispatchable.
on:
release:
types: [published]
workflow_dispatch:

jobs:
install-from-tap:
runs-on: macos-latest
steps:
- name: Install from the live tap (retrying while the release propagates)
run: |
attempts=5
for i in $(seq 1 "$attempts"); do
if brew install clintmod/formulas/macprefs; then
exit 0
fi
echo "attempt $i/$attempts failed - waiting for tap/tarball propagation"
sleep 60
done
echo "install still failing after $attempts attempts" >&2
exit 1

- name: Verify the installed version matches the release tag
if: github.event_name == 'release'
run: |
installed="$(macprefs --version)"
echo "installed: $installed / released: ${{ github.event.release.tag_name }}"
test "$installed" = "${{ github.event.release.tag_name }}"

- name: Run a real backup with the installed package
run: |
MACPREFS_BACKUP_DIR="$RUNNER_TEMP/backup" macprefs backup
test -d "$RUNNER_TEMP/backup/preferences"
36 changes: 35 additions & 1 deletion Ritefile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,46 @@ tasks:
- uv run pylint src/*.py src/macprefs tests/*.py

lint:ruff:
desc: Run ruff check + format check
desc: Run ruff check
deps: [setup]
cmds:
- uv run ruff check src tests

format:check:
desc: Check formatting without changing files
aliases: [fc]
deps: [setup]
cmds:
- uv run ruff format --check src tests

typecheck:
desc: Type-check with pyright (basic mode; untyped codebase)
aliases: [tc]
deps: [setup]
cmds:
- uv run pyright src tests

bandit:
desc: Security-lint the source with bandit
deps: [setup]
cmds:
- uv run bandit -c pyproject.toml -q -r src

deptry:
desc: Check for missing/unused dependencies
deps: [setup]
cmds:
- uv run deptry .

gitleaks:
desc: Scan the full git history for leaked secrets
cmds:
- gitleaks git --no-banner

check:
desc: Run every check (what CI runs)
deps: [test, lint, format:check, typecheck, bandit, deptry, gitleaks, audit]

lint:shell:
desc: Shellcheck the ci scripts
cmds:
Expand Down
1 change: 1 addition & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ uv = "latest"
"github:clintmod/rite" = "latest"
shellcheck = "latest"
actionlint = "latest"
gitleaks = "latest"
29 changes: 29 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ license = { file = "LICENSE" }

[dependency-groups]
dev = [
"bandit>=1.9.4",
"deptry>=0.25.1",
"pip-audit==2.10.1",
"pylint==4.0.6",
"pyright>=1.1.411",
"pytest==9.1.1",
"pytest-cov==7.1.0",
"pytest-testmon==2.2.0",
Expand Down Expand Up @@ -82,3 +85,29 @@ include = ["src/**/*.py", "tests/**/*.py", "src/macprefs"]
[tool.ruff.lint]
# Default rules (pyflakes + core pycodestyle) plus import sorting.
extend-select = ["I"]

[tool.pyright]
include = ["src", "tests"]
extraPaths = ["src"]
# Untyped codebase: basic mode checks inference-visible errors only.
typeCheckingMode = "basic"

[tool.bandit]
# This tool's entire job is shelling out (rsync, chown, killall, curl) --
# subprocess usage is by design, and commands are built from config, not
# user input. Suppress the blanket subprocess advisories; keep the rest
# (e.g. B602 shell=True stays active).
skips = ["B404", "B603", "B607"]
exclude_dirs = ["tests", ".venv"]

[tool.deptry]
# Flat modules in src/ import each other as top-level names.
known_first_party = [
"app_store_preferences", "config", "dotfiles", "internet_accounts",
"macprefs", "preferences", "publish", "shared_file_lists", "ssh_files",
"startup_items", "system_preferences", "utils", "version",
]

# Pytest plugins and CLI tools are invoked, never imported.
[tool.deptry.per_rule_ignores]
DEP002 = ["pylint", "pytest-cov", "pytest-testmon", "pytest-watch", "ruff", "pip-audit", "pyright", "deptry", "bandit"]
6 changes: 4 additions & 2 deletions src/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import getpass
from os import environ, getenv, makedirs, path
from os import environ, makedirs, path


def get_macprefs_dir():
Expand Down Expand Up @@ -50,7 +50,9 @@ def get_dotfile_excludes():


def get_home_dir():
return getenv("HOME") + "/"
# environ (not getenv): a missing HOME should raise a clear KeyError
# instead of a TypeError from None + str.
return environ["HOME"] + "/"


def ensure_exists(input_dir):
Expand Down
9 changes: 6 additions & 3 deletions src/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def create_version_tag_and_push(tag):

def download_tar(filename):
print("Downloading the new version...")
urllib.request.urlretrieve("https://github.com/clintmod/macprefs/archive/" + filename, filename)
urllib.request.urlretrieve("https://github.com/clintmod/macprefs/archive/" + filename, filename) # nosec B310 - fixed https url


def calc_sha256(filename):
Expand Down Expand Up @@ -55,7 +55,9 @@ def create_brew_formula_file_content(version, sha256):
def get_sha_of_old_macprefs_formula():
print("Getting sha of old macprefs formula from github...")
result = json.load(
urllib.request.urlopen("https://api.github.com/repos/clintmod/homebrew-formulas/contents/Formula/macprefs.rb")
urllib.request.urlopen( # nosec B310 - fixed https url
"https://api.github.com/repos/clintmod/homebrew-formulas/contents/Formula/macprefs.rb"
)
)
# print 'sha = ' + result['sha']
return result["sha"]
Expand Down Expand Up @@ -114,7 +116,8 @@ def download_macprefs():
def verify_macprefs():
result = execute_shell(["macprefs", "--version"])
message = "\nworkspace:\t" + __version__ + "\ninstalled:\t" + result
assert __version__ in result, message
if __version__ not in result:
raise AssertionError(message)
print("version check verified" + message)


Expand Down
14 changes: 10 additions & 4 deletions src/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import importlib
import importlib.machinery
import importlib.util
import logging as log
import sys
from subprocess import STDOUT, CalledProcessError, check_output
Expand All @@ -10,7 +11,9 @@ def execute_shell(command, is_shell=False, cwd=".", suppress_errors=False):
log.debug("setting working dir to: %s", cwd)
log.debug("command: %s", str(command))
try:
output = check_output(command, shell=is_shell, cwd=cwd, stderr=STDOUT).strip().decode("utf-8")
# is_shell=True is only ever used with fixed command strings built
# from config, never user input.
output = check_output(command, shell=is_shell, cwd=cwd, stderr=STDOUT).strip().decode("utf-8") # nosec B602
log.debug("output = %s", output)
except CalledProcessError as err:
log.error("Error Info:\nerror code = %s\ncmd %s\nerror message:%s", err.returncode, err.cmd, err.output)
Expand Down Expand Up @@ -131,8 +134,11 @@ def is_none_or_empty_string(val):


def execute_module(name, path):
spec = importlib.util.spec_from_loader(name, importlib.machinery.SourceFileLoader(name, path))
loader = importlib.machinery.SourceFileLoader(name, path)
spec = importlib.util.spec_from_loader(name, loader)
if spec is None:
raise ImportError("cannot build a module spec for " + path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
loader.exec_module(mod)
sys.modules[name] = mod
return mod
Loading
Loading