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
40 changes: 27 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@ on:
branches: [master]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

# Installs the pinned toolchain (python, uv, rite) from mise.toml,
# so CI runs the exact same tasks as a dev machine.
- uses: jdx/mise-action@v4
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0

- name: Test
run: rite test
Expand All @@ -28,38 +33,43 @@ jobs:
name: lint:${{ matrix.check }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- uses: jdx/mise-action@v4
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0

- name: Lint (${{ matrix.check }})
run: rite lint:${{ matrix.check }}
run: "rite lint:${{ matrix.check }}"

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

- uses: jdx/mise-action@v4
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0

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

gitleaks:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# gitleaks scans the full commit history
fetch-depth: 0
persist-credentials: false

- uses: jdx/mise-action@v4
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0

- name: Scan git history for secrets
run: rite gitleaks
Expand All @@ -70,9 +80,11 @@ jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- uses: jdx/mise-action@v4
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0

- name: Build, install, and smoke test the brew package
run: rite build
Expand All @@ -92,7 +104,9 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Bootstrap per README and run tests
run: |
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: CodeQL

on:
push:
branches: [master]
pull_request:
schedule:
- cron: "31 7 * * 1" # weekly, monday morning

permissions:
contents: read

jobs:
analyze:
runs-on: macos-latest
permissions:
security-events: write
actions: read
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
languages: python

- uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
23 changes: 21 additions & 2 deletions .github/workflows/release-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,29 @@ on:
types: [published]
workflow_dispatch:

permissions:
contents: read

jobs:
install-from-tap:
runs-on: macos-latest
env:
# Indirection instead of inlining the template into run: blocks —
# release names are attacker-influencable in forks and zizmor
# rightly flags template injection.
RELEASE_TAG: ${{ github.event.release.tag_name }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Verify version.py matches the release tag
if: github.event_name == 'release'
run: |
workspace_version="$(PYTHONPATH=src python3 -c 'import version; print(version.__version__)')"
echo "version.py: $workspace_version / release tag: $RELEASE_TAG"
test "$workspace_version" = "$RELEASE_TAG"

- name: Install from the live tap (retrying while the release propagates)
run: |
attempts=5
Expand All @@ -31,8 +50,8 @@ jobs:
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 }}"
echo "installed: $installed / released: $RELEASE_TAG"
test "$installed" = "$RELEASE_TAG"

- name: Run a real backup with the installed package
run: |
Expand Down
14 changes: 11 additions & 3 deletions Ritefile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ tasks:
aliases: [t]
deps: [setup]
cmds:
- uv run pytest --cov=src --cov-fail-under=95 --cov-report xml:cov.xml --cov-report term-missing
- uv run pytest --cov=src --cov-branch --cov-fail-under=95 --cov-report xml:cov.xml --cov-report term-missing

test:watch:
desc: Re-run tests on file changes
Expand Down Expand Up @@ -93,17 +93,25 @@ tasks:

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

lint:shell:
desc: Shellcheck the ci scripts
cmds:
- shellcheck ci/scripts/*.sh

lint:actions:
desc: Lint the GitHub Actions workflows
desc: Lint and security-audit the GitHub Actions workflows
deps: [setup]
cmds:
- actionlint
- uv run zizmor .github/workflows

lock:check:
desc: Verify uv.lock is in sync with pyproject.toml
aliases: [lc]
cmds:
- uv lock --check

format:
desc: Format the codebase with ruff (also sorts imports)
Expand Down
11 changes: 7 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,26 @@ license = { file = "LICENSE" }

[dependency-groups]
dev = [
"bandit>=1.9.4",
"deptry>=0.25.1",
"bandit==1.9.4",
"deptry==0.25.1",
"pip-audit==2.10.1",
"pylint==4.0.6",
"pyright>=1.1.411",
"pyright==1.1.411",
"pytest==9.1.1",
"pytest-cov==7.1.0",
"pytest-randomly==4.1.0",
"pytest-testmon==2.2.0",
"pytest-timeout==2.4.0",
"pytest-watch==4.2.0",
"ruff==0.15.22",
"zizmor==1.27.0",
]

[tool.uv]
package = false

[tool.pytest.ini_options]
addopts = "--maxfail=2 -s --tb=native -v -m 'not integration'"
addopts = "--maxfail=2 -s --tb=native -v --timeout=60 -m 'not integration'"
markers = ["integration: integration tests"]
pythonpath = ["src"]
testpaths = ["tests"]
Expand Down
64 changes: 62 additions & 2 deletions tests/test_macprefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ def test_invoke_no_args(mock_stdout):
@patch("preferences.backup")
# pylint: disable-msg=too-many-arguments
def test_backup(
system_preferences_mock,
preferences_mock,
system_preferences_mock,
shared_files_mock,
dotfiles_mock,
ssh_mock,
Expand Down Expand Up @@ -95,8 +95,8 @@ def test_backup(
@patch("preferences.restore")
# pylint: disable-msg=too-many-arguments
def test_restore(
system_preferences_mock,
preferences_mock,
system_preferences_mock,
shared_files_mock,
dotfiles_mock,
ssh_mock,
Expand All @@ -115,6 +115,66 @@ def test_restore(
internet_accounts_mock.assert_called_once()


@patch("internet_accounts.backup")
@patch("app_store_preferences.backup")
@patch("startup_items.backup")
@patch("ssh_files.backup")
@patch("dotfiles.backup")
@patch("shared_file_lists.backup")
@patch("system_preferences.backup")
@patch("preferences.backup")
# pylint: disable-msg=too-many-arguments
def test_backup_selective(
preferences_mock,
system_preferences_mock,
shared_files_mock,
dotfiles_mock,
ssh_mock,
startup_mock,
app_store_mock,
internet_accounts_mock,
):
macprefs.backup(["dotfiles"])
dotfiles_mock.assert_called_once()
system_preferences_mock.assert_not_called()
preferences_mock.assert_not_called()
shared_files_mock.assert_not_called()
ssh_mock.assert_not_called()
startup_mock.assert_not_called()
app_store_mock.assert_not_called()
internet_accounts_mock.assert_not_called()


@patch("internet_accounts.restore")
@patch("app_store_preferences.restore")
@patch("startup_items.restore")
@patch("ssh_files.restore")
@patch("dotfiles.restore")
@patch("shared_file_lists.restore")
@patch("system_preferences.restore")
@patch("preferences.restore")
# pylint: disable-msg=too-many-arguments
def test_restore_selective(
preferences_mock,
system_preferences_mock,
shared_files_mock,
dotfiles_mock,
ssh_mock,
startup_mock,
app_store_mock,
internet_accounts_mock,
):
macprefs.restore(["ssh_files", "preferences"])
ssh_mock.assert_called_once()
preferences_mock.assert_called_once()
system_preferences_mock.assert_not_called()
shared_files_mock.assert_not_called()
dotfiles_mock.assert_not_called()
startup_mock.assert_not_called()
app_store_mock.assert_not_called()
internet_accounts_mock.assert_not_called()


def assert_correct_std_out(e, mock_stdout):
assert e.code == 0
assert "usage: macprefs" in mock_stdout.getvalue()
Expand Down
Loading
Loading