diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e7cb5e..02d38be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -28,25 +33,29 @@ 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 }}" @@ -54,12 +63,13 @@ jobs: 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 @@ -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 @@ -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: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5120264 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -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 diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index e02b16b..58e46d8 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -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 @@ -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: | diff --git a/Ritefile.yml b/Ritefile.yml index 39da91a..7e2d0db 100644 --- a/Ritefile.yml +++ b/Ritefile.yml @@ -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 @@ -93,7 +93,7 @@ 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 @@ -101,9 +101,17 @@ tasks: - 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) diff --git a/pyproject.toml b/pyproject.toml index d9e5a50..c4ecc0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/test_macprefs.py b/tests/test_macprefs.py index b61916f..7696dfc 100644 --- a/tests/test_macprefs.py +++ b/tests/test_macprefs.py @@ -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, @@ -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, @@ -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() diff --git a/uv.lock b/uv.lock index af404d1..fc7aded 100644 --- a/uv.lock +++ b/uv.lock @@ -426,25 +426,31 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-randomly" }, { name = "pytest-testmon" }, + { name = "pytest-timeout" }, { name = "pytest-watch" }, { name = "ruff" }, + { name = "zizmor" }, ] [package.metadata] [package.metadata.requires-dev] dev = [ - { name = "bandit", specifier = ">=1.9.4" }, - { name = "deptry", specifier = ">=0.25.1" }, + { name = "bandit", specifier = "==1.9.4" }, + { name = "deptry", specifier = "==0.25.1" }, { name = "pip-audit", specifier = "==2.10.1" }, { name = "pylint", specifier = "==4.0.6" }, - { name = "pyright", specifier = ">=1.1.411" }, + { name = "pyright", specifier = "==1.1.411" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-cov", specifier = "==7.1.0" }, + { name = "pytest-randomly", specifier = "==4.1.0" }, { name = "pytest-testmon", specifier = "==2.2.0" }, + { name = "pytest-timeout", specifier = "==2.4.0" }, { name = "pytest-watch", specifier = "==4.2.0" }, { name = "ruff", specifier = "==0.15.22" }, + { name = "zizmor", specifier = "==1.27.0" }, ] [[package]] @@ -744,6 +750,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-randomly" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/b3/36192dacc0f470ac2cc516f73e01739c9a48a8224f76beada4f85e1c8a89/pytest_randomly-4.1.0.tar.gz", hash = "sha256:47f1d9746c3bc3efabd53ae1ebfb8bb385cf3d4df4b505b6d58d9c97a3dfe70f", size = 14302, upload-time = "2026-04-20T13:01:51.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" }, +] + [[package]] name = "pytest-testmon" version = "2.2.0" @@ -757,6 +775,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/55/ebb3c2f59fb089f08d00f764830d35780fc4e4c41dffcadafa3264682b65/pytest_testmon-2.2.0-py3-none-any.whl", hash = "sha256:2604ca44a54d61a2e830d9ce828b41a837075e4ebc1f81b148add8e90d34815b", size = 25199, upload-time = "2025-12-01T07:30:23.623Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "pytest-watch" version = "4.2.0" @@ -1053,3 +1083,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] + +[[package]] +name = "zizmor" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/a8/1c0c6619e213e6e1fd8323f09661542197448db2984649d2c34097997859/zizmor-1.27.0.tar.gz", hash = "sha256:ffaf8e1bb39447e643592d669761bfbc2aa636875db9079614f6a6c81cec60c8", size = 548896, upload-time = "2026-07-14T08:16:49.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/81/ba3dbd48a5e3d56d8a9693ba3dac9484df1b3f83862a453ade9f70ef21ff/zizmor-1.27.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0de272b6c19910f5bc6965ac7d4566af26db31571e983e3b567298389d0c84c7", size = 9090700, upload-time = "2026-07-14T08:16:31.04Z" }, + { url = "https://files.pythonhosted.org/packages/2a/18/960f032faa67427afb8b3e11877b7e911d1ccff3f3bb8bec0c416d4df42c/zizmor-1.27.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cc94158472428e04298b879ef36cb94360b753ece38fa2303464dd7dae07bc68", size = 8673964, upload-time = "2026-07-14T08:16:33.038Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2a/034e4d11d1ed81e23fc9e526391616848467f0db6faeaa9e1b88fb8560ec/zizmor-1.27.0-py3-none-manylinux_2_24_aarch64.whl", hash = "sha256:b70697ebe555a28fbd3deaa259936f059f64b9f8531020f5b1f3d99087d4f62a", size = 8777649, upload-time = "2026-07-14T08:16:34.831Z" }, + { url = "https://files.pythonhosted.org/packages/2e/27/8dce2cd076ee0c39b0d9ac129703400c9c1c21c71f1709da660e3769d628/zizmor-1.27.0-py3-none-manylinux_2_28_armv7l.whl", hash = "sha256:0bba3eff43f919b04b9b6365b4ef17437649e11eafb73f603407873b65ad01dd", size = 8367119, upload-time = "2026-07-14T08:16:36.859Z" }, + { url = "https://files.pythonhosted.org/packages/21/64/4e21d26a2fc910594aaab19367ac25467a4da6438b7c381f67e90c38945e/zizmor-1.27.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:afb28123882d2b8248f1e480bf6cc6d1af102e0d3fbe22a40f7f795b1aa9d435", size = 9163603, upload-time = "2026-07-14T08:16:38.591Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/17ffd2884f8d74ce747c6ffe11481de5effb35079fd00a0725fab336e57d/zizmor-1.27.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e1042dd14fb4597a1e3007c7ef3f5a017305ca50cfacf43d603f3ae870c65eb1", size = 8807297, upload-time = "2026-07-14T08:16:40.804Z" }, + { url = "https://files.pythonhosted.org/packages/60/af/3a347da3007655c7979b4b99d6a4d309ebc344715daa7c1357f17d00d680/zizmor-1.27.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:138e65d560e3875dbbbec50eae56a9e14707f8f4e006112174e4752155c01db9", size = 8333630, upload-time = "2026-07-14T08:16:42.814Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/921c74ed022fe8e1f599d7e7353cab63d550dda68e410096ed735b7388db/zizmor-1.27.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90004342c3c5122d7e4e8e27ac6d3c462b878da0ea3d408ff45fa7d768139a76", size = 9253035, upload-time = "2026-07-14T08:16:44.354Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/9cdb03c6c2341cf273c9c34506e5193cea7e73dc50b49ccf5f93a3579bdb/zizmor-1.27.0-py3-none-win32.whl", hash = "sha256:fe8947f635c925b83ef83fcd66de5f89a012b22784bb86fcd7e2a1f4e7648c9d", size = 7538509, upload-time = "2026-07-14T08:16:46.253Z" }, + { url = "https://files.pythonhosted.org/packages/43/42/b924e569218646684d1e211f299282c0b9a0780d6b15f9e10e4a3fdaac11/zizmor-1.27.0-py3-none-win_amd64.whl", hash = "sha256:debc723721172c170d5922171a40eaebf5787a02ff3e69d30597dafdb66a21ba", size = 8643618, upload-time = "2026-07-14T08:16:47.835Z" }, +]