diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fb4bacb3..fdc42dd4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,6 +16,6 @@ Outputs renamed or removed: none ## Checks - [ ] Action smoke test passes on this PR -- [ ] Docs and `examples/` use `bawbel/integrations@v1` +- [ ] Docs and `examples/` pin every action and `rev` by commit SHA (`python scripts/check_pins.py`) - [ ] README and `examples/` match any changed action input or output - [ ] No em dashes diff --git a/.github/workflows/action-smoke.yml b/.github/workflows/action-smoke.yml index de2640fb..527b54dd 100644 --- a/.github/workflows/action-smoke.yml +++ b/.github/workflows/action-smoke.yml @@ -22,7 +22,7 @@ jobs: - name: Run bawbel/integrations@v1 on a fixture with a known finding id: bawbel # Deliberately unpinned: this job tests the public v1 tag that users copy. - uses: bawbel/integrations@v1 + uses: bawbel/integrations@v1 # pin-exempt: tests the public moving tag that users copy with: path: test_skill.md recursive: "false" diff --git a/.github/workflows/pins.yml b/.github/workflows/pins.yml new file mode 100644 index 00000000..aeb49017 --- /dev/null +++ b/.github/workflows/pins.yml @@ -0,0 +1,26 @@ +# Every action and pre-commit rev in this repo is pinned by commit SHA (RELEASING.md). +# fetch-depth 0 brings the tags, which the checker needs to compare a SHA with its release. +name: Pins + +on: + pull_request: + push: + branches: [develop, main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + pins: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + + - name: Every action and rev is pinned by commit SHA + run: python3 scripts/check_pins.py + + - name: Pin checker tests + run: python3 -m unittest discover -s tests -p "test_check_pins.py" diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 6749f74f..67a53458 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -5,7 +5,7 @@ # # repos: # - repo: https://github.com/bawbel/bawbel-integrations -# rev: v1 +# rev: # frozen: # hooks: # - id: bawbel-scan diff --git a/README.md b/README.md index 5fdff016..27abd27a 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,10 @@ Scan on every push and pull request. Findings appear as inline PR annotations in the GitHub Security tab via SARIF upload. Blocks merges on CRITICAL or HIGH findings. -The action reference is `bawbel/integrations@v1`. Use that name, not -`bawbel/scanner` or `bawbel/bawbel-integrations`. +The action reference is `bawbel/integrations`. Use that name, not +`bawbel/scanner` or `bawbel/bawbel-integrations`. Pin it to a commit SHA and write the +release in a trailing comment, as below: a tag can be moved, a commit cannot. +See [Pinning and releases](#pinning-and-releases). ```yaml # .github/workflows/bawbel.yml @@ -46,12 +48,12 @@ jobs: security-events: write contents: read steps: - - uses: actions/checkout@v4 - - uses: bawbel/integrations@v1 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: bawbel/integrations@ee77de3c8fb927a41ca9d8d5f51be22215978d14 # v1.1.0 with: path: . fail-on-severity: high - - uses: github/codeql-action/upload-sarif@v3 + - uses: github/codeql-action/upload-sarif@ce5e7eeac396d720fe2d802714996c8ce561e311 # v3.39.0 if: always() with: sarif_file: bawbel-results.sarif @@ -70,6 +72,21 @@ jobs: See [`action.yml`](action.yml) for full input/output reference. +### Pinning and releases + +Releases are tagged `vMAJOR.MINOR.PATCH` and a release tag is never moved. `v1` is a moving +tag that points at the newest `v1.x.y` release. Use it to try the action, not in a pipeline +you rely on. The policy and the release steps are in [RELEASING.md](RELEASING.md). + +To pin, take the commit of the release you want and write the release in a comment: + +```bash +gh api repos/bawbel/integrations/commits/v1.1.0 --jq .sha +``` + +Dependabot with the `github-actions` ecosystem keeps SHA pins current. This repo uses it +for its own workflows (`.github/dependabot.yml`). + --- ## VS Code Extension @@ -121,11 +138,17 @@ Block malicious skills at the commit boundary — before they reach CI. pre-commit automatically installs `bawbel-scanner` in an isolated virtualenv. No manual `pip install` needed. +No release contains these hooks yet. `.pre-commit-hooks.yaml` was added after v1.1.0, so +`rev: v1` and `rev: v1.1.0` fail with +`InvalidManifestError: .pre-commit-hooks.yaml is not a file`. Until a release contains +it, use Option 2. After that, set `rev` to the commit SHA of that release, with the tag in a +comment as below. + ```yaml # .pre-commit-config.yaml repos: - repo: https://github.com/bawbel/bawbel-integrations - rev: v1 + rev: # frozen: hooks: - id: bawbel-scan # pattern engine only (~15ms per file) ``` @@ -135,7 +158,7 @@ All engines (YARA + Semgrep + Magika — slower, more thorough): ```yaml repos: - repo: https://github.com/bawbel/bawbel-integrations - rev: v1 + rev: # frozen: hooks: - id: bawbel-scan-all ``` @@ -145,7 +168,7 @@ Custom severity threshold: ```yaml repos: - repo: https://github.com/bawbel/bawbel-integrations - rev: v1 + rev: # frozen: hooks: - id: bawbel-scan args: ["--fail-on-severity", "critical"] diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..213b3f16 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,67 @@ +# Releases and pinning + +How `bawbel/integrations` is released and what its tags mean. Maintainers cut releases. +Nothing here runs by itself. + +## Tags + +| Tag | Meaning | Moves? | +|---|---|---| +| `vX.Y.Z` (for example `v1.1.0`) | One release | Never. A release tag is not moved, deleted, or re-pointed. A bad release is fixed by a new patch release. | +| `vX` (for example `v1`) | The newest `vX.Y.Z` release of that major version | Yes, and only as described below | + +`v1` moves only to a `v1.Y.Z` release tag, and only after the release steps below have passed. +It never moves to a commit that is not a tagged release, and never to a different major +version. If a release turns out to be bad, `v1` moves back to the last good release tag, a +patch release follows, and the release notes say so. + +A change that breaks existing workflows is a new major version (`v2`), and `v1` stays where +it is. That covers renaming or removing an input or an output, and changing a default so that +a passing workflow fails or the other way round. + +## Pinning, for people who use the action + +Pin to the commit SHA of a release and write the release in a trailing comment: + +```yaml +- uses: bawbel/integrations@ee77de3c8fb927a41ca9d8d5f51be22215978d14 # v1.1.0 +``` + +```bash +gh api repos/bawbel/integrations/commits/v1.1.0 --jq .sha +``` + +`v1` is for trying the action. It changes under you by design. + +## This repository's own workflows + +Every `uses:` and `rev:` in `.github/workflows/`, `README.md`, `examples/`, +`bawbel_hooks/README.md`, and `.pre-commit-hooks.yaml` is a 40-character commit SHA with the +release in a trailing comment. `scripts/check_pins.py` enforces that in CI (the `Pins` +workflow). It also checks that a SHA written for this repo's own action is the commit its +comment's tag points to. It does not compare third-party SHAs with their upstream tags. + +There is one exception: `.github/workflows/action-smoke.yml` runs the public `v1` tag on +purpose, because it tests what a user who copies `@v1` gets. The line carries +`# pin-exempt: `. + +A component that no release contains yet is documented with the placeholder +``. The checker lists each one so the release PR can fill them in. +Dependabot (`.github/dependabot.yml`) updates the pins in workflows every week. It does not +edit the README or `examples/`. + +## Cutting a release + +1. `develop` is green: `Action tests (working tree)`, `Gate lint action tests (working tree)` + and `Pins`. The README matches `action.yml` and `gate-lint/action.yml`. +2. Promote `develop` to `main` with a pull request that a person reviews. +3. Tag the merge commit on `main` as `vX.Y.Z` and push the tag. Write the release notes: + what changed, and anything that makes a workflow behave differently. +4. Move the major tag to it: + `git tag -f v1 vX.Y.Z && git push --force origin refs/tags/v1` +5. Run `Action smoke test` from the Actions tab. It uses the public `v1`. If it fails, move + `v1` back to the previous release, then fix forward with a patch release. +6. Open a docs pull request that sets the SHA in `README.md` and `examples/` to the new + release, and fills in any `` placeholder. Run + `python scripts/check_pins.py` first: it names every line to change and rejects a SHA that + does not match its tag. diff --git a/bawbel_hooks/README.md b/bawbel_hooks/README.md index 8de1a64e..d65fed70 100644 --- a/bawbel_hooks/README.md +++ b/bawbel_hooks/README.md @@ -12,11 +12,17 @@ Add to `.pre-commit-config.yaml`: ```yaml repos: - repo: https://github.com/bawbel/bawbel-integrations - rev: v1 + rev: # frozen: hooks: - id: bawbel-scan ``` +No release contains these hooks yet. `.pre-commit-hooks.yaml` was added after v1.1.0, so +`rev: v1` and `rev: v1.1.0` fail with +`InvalidManifestError: .pre-commit-hooks.yaml is not a file`. Until a release contains +it, use Option 2. After that, set `rev` to the commit SHA of that release, with the tag in a +comment as below. + That's it. On every `git commit`, Bawbel will scan your staged `.md`, `.yaml`, and `.json` files for AVE vulnerabilities. diff --git a/examples/basic.yml b/examples/basic.yml index 6cedad5a..55544db1 100644 --- a/examples/basic.yml +++ b/examples/basic.yml @@ -6,8 +6,8 @@ jobs: scan: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: bawbel/integrations@v1 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: bawbel/integrations@ee77de3c8fb927a41ca9d8d5f51be22215978d14 # v1.1.0 with: path: . fail-on-severity: high diff --git a/examples/sarif.yml b/examples/sarif.yml index 237c9f6d..4b642b56 100644 --- a/examples/sarif.yml +++ b/examples/sarif.yml @@ -9,10 +9,10 @@ jobs: security-events: write contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Scan for AVE vulnerabilities - uses: bawbel/integrations@v1 + uses: bawbel/integrations@ee77de3c8fb927a41ca9d8d5f51be22215978d14 # v1.1.0 id: bawbel with: path: . @@ -20,7 +20,7 @@ jobs: format: sarif - name: Upload to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@ce5e7eeac396d720fe2d802714996c8ce561e311 # v3.39.0 if: always() with: sarif_file: ${{ steps.bawbel.outputs.sarif-file }} diff --git a/scripts/check_pins.py b/scripts/check_pins.py new file mode 100644 index 00000000..28d9173d --- /dev/null +++ b/scripts/check_pins.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Check that every action and pre-commit reference in this repo is pinned by commit SHA. + +A tag can be moved, a commit SHA cannot. This repo ships an action that tells people to +scan their supply chain, so its own workflows and docs pin what they use (RELEASING.md). + +Checked files: .github/workflows, README.md, examples, bawbel_hooks/README.md, +.pre-commit-hooks.yaml. Checked lines: + + uses: owner/repo[/path]@REF REF must be 40 lowercase hex characters + rev: REF same rule + +and each needs a trailing comment that names the release, `# v1.1.0` or `# frozen: v1.1.0`. +Not checked: local actions (`uses: ./...`) and docker:// references. + +Two escapes, both visible in the output: + + # pin-exempt: on the same line. For a job that tests a moving tag on purpose. + @ the literal placeholder, for a component that no release + contains yet. Listed so the release PR can fill them in. + +A reference to this repo's own action (`bawbel/integrations`) is also compared with the +tag its comment names: the tag must exist here and resolve to that SHA. That needs the +tags, so CI checks out with fetch-depth 0. + +Exit 0: all pinned. Exit 1: at least one line failed. Exit 2: could not run. +""" + +import argparse +import re +import subprocess # nosec B404 # noqa: S404 -- runs git on this repo +import sys +from pathlib import Path + +OWN_ACTION = "bawbel/integrations" +CHECKED = ( + ".github/workflows/*.yml", + ".github/workflows/*.yaml", + "README.md", + "examples/**/*.yml", + "examples/**/*.yaml", + "bawbel_hooks/README.md", + ".pre-commit-hooks.yaml", +) +PLACEHOLDER = "" +EXEMPT_MARK = "pin-exempt:" + +SHA = re.compile(r"^[0-9a-f]{40}$") +USES = re.compile(r"^\s*#?\s*(?:-\s*)?uses:\s*(?P\S+)\s*(?:#\s*(?P.*))?$") +REV = re.compile(r"^\s*#?\s*rev:\s*(?P\S.*?)\s*(?:#\s*(?P.*))?$") +RELEASE_NOTE = re.compile(r"^(?:frozen:\s*)?(?Pv\d+(?:\.\d+){0,2})\s*$") + + +def tag_commit(root: Path, tag: str) -> str | None: + """The commit a tag points at in this repo, or None when the tag is not there.""" + out = subprocess.run( # nosec B603 B607 # noqa: S603 S607 -- fixed argv, no shell + ["git", "-C", str(root), "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}^{{commit}}"], + capture_output=True, + text=True, + check=False, + ) + return out.stdout.strip() or None + + +def check_line(root: Path, text: str) -> tuple[str, str] | None: + """Return ('error' | 'placeholder', message) for one line, or None when it is fine.""" + match = USES.match(text) or REV.match(text) + if not match: + return None + ref, note = match.group("ref"), (match.group("note") or "").strip() + is_uses = bool(USES.match(text)) + + if note.startswith(EXEMPT_MARK): + return None + if is_uses: + if ref.startswith(("./", "docker://")) or "@" not in ref: + return None + action, _, pin = ref.rpartition("@") + else: + action, pin = "", ref + + if pin == PLACEHOLDER: + return ("placeholder", f"{ref}: fill in when a release contains it") + if not SHA.match(pin): + return ("error", f"{ref}: pin to a 40-character commit SHA, not '{pin}'") + release = RELEASE_NOTE.match(note) + if not release: + return ("error", f"{ref}: add the release as a trailing comment, '# v1.2.3'") + if action == OWN_ACTION or action.startswith(OWN_ACTION + "/"): + resolved = tag_commit(root, release.group("tag")) + if resolved is None: + return ("error", f"{ref}: tag {release.group('tag')} is not in this checkout") + if resolved != pin: + return ("error", f"{ref}: tag {release.group('tag')} is {resolved}, not {pin}") + return None + + +def checked_files(root: Path) -> list[Path]: + found: set[Path] = set() + for pattern in CHECKED: + found.update(p for p in root.glob(pattern) if p.is_file()) + return sorted(found) + + +def run(root: Path) -> tuple[list[str], list[str]]: + errors: list[str] = [] + placeholders: list[str] = [] + for path in checked_files(root): + for number, text in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + verdict = check_line(root, text) + if verdict is None: + continue + where = f"{path.relative_to(root)}:{number}" + (errors if verdict[0] == "error" else placeholders).append(f"{where}: {verdict[1]}") + return errors, placeholders + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent) + args = parser.parse_args(argv) + if not args.root.is_dir(): + print(f"error: {args.root} is not a directory", file=sys.stderr) + return 2 + + errors, placeholders = run(args.root) + for line in placeholders: + print(f"placeholder {line}") + for line in errors: + print(f"not pinned {line}") + if errors: + print(f"{len(errors)} reference(s) not pinned", file=sys.stderr) + return 1 + print(f"all references pinned ({len(placeholders)} placeholder(s) to fill at release)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_pins.py b/tests/test_check_pins.py new file mode 100644 index 00000000..71f68a29 --- /dev/null +++ b/tests/test_check_pins.py @@ -0,0 +1,193 @@ +"""Tests for scripts/check_pins.py. Standard library only: python -m unittest discover tests""" + +import subprocess # nosec B404 # noqa: S404 -- builds a throwaway git repo +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) + +import check_pins # noqa: E402 + +SHA_A = "11d5960a326750d5838078e36cf38b85af677262" +SHA_B = "ee77de3c8fb927a41ca9d8d5f51be22215978d14" + + +def git(root: Path, *args: str) -> str: + out = subprocess.run( # nosec B603 B607 # noqa: S603 S607 -- fixed argv, no shell + ["git", "-C", str(root), *args], capture_output=True, text=True, check=True + ) + return out.stdout.strip() + + +def make_repo(root: Path) -> str: + """A repo with one commit tagged v1.1.0. Returns the commit SHA.""" + git(root, "init", "-q") + git(root, "config", "user.email", "t@example.invalid") + git(root, "config", "user.name", "t") + (root / "f").write_text("x") + git(root, "add", "f") + git(root, "commit", "-q", "-m", "c") + sha = git(root, "rev-parse", "HEAD") + git(root, "update-ref", "refs/tags/v1.1.0", sha) + return sha + + +class TestCheckLine(unittest.TestCase): + def verdict(self, text, root=ROOT): + return check_pins.check_line(root, text) + + def test_pinned_with_release_comment_passes(self): + self.assertIsNone(self.verdict(f" - uses: actions/checkout@{SHA_A} # v4.4.0")) + + def test_pinned_step_with_name_key_passes(self): + self.assertIsNone(self.verdict(f" uses: github/codeql-action/upload-sarif@{SHA_A} # v3.39.0")) + + def test_moving_tag_fails(self): + kind, message = self.verdict(" - uses: actions/checkout@v4") + self.assertEqual(kind, "error") + self.assertIn("40-character commit SHA", message) + + def test_branch_ref_fails(self): + self.assertEqual(self.verdict(" - uses: actions/checkout@main")[0], "error") + + def test_short_sha_fails(self): + self.assertEqual(self.verdict(f" - uses: actions/checkout@{SHA_A[:12]} # v4.4.0")[0], "error") + + def test_uppercase_sha_fails(self): + self.assertEqual( + self.verdict(f" - uses: actions/checkout@{SHA_A.upper()} # v4.4.0")[0], "error" + ) + + def test_missing_release_comment_fails(self): + kind, message = self.verdict(f" - uses: actions/checkout@{SHA_A}") + self.assertEqual(kind, "error") + self.assertIn("trailing comment", message) + + def test_comment_that_is_not_a_release_fails(self): + self.assertEqual(self.verdict(f" - uses: actions/checkout@{SHA_A} # latest")[0], "error") + + def test_local_action_is_skipped(self): + self.assertIsNone(self.verdict(" uses: ./")) + self.assertIsNone(self.verdict(" uses: ./gate-lint")) + + def test_docker_reference_is_skipped(self): + self.assertIsNone(self.verdict(" uses: docker://alpine:3.20")) + + def test_exempt_mark_skips_with_a_reason(self): + self.assertIsNone( + self.verdict(" uses: bawbel/integrations@v1 # pin-exempt: tests the public tag") + ) + + def test_placeholder_is_reported_not_failed(self): + kind, _ = self.verdict(" - uses: bawbel/integrations/gate-lint@ # ") + self.assertEqual(kind, "placeholder") + + def test_rev_needs_a_sha_too(self): + self.assertEqual(self.verdict(" rev: v1")[0], "error") + self.assertIsNone(self.verdict(f" rev: {SHA_B} # frozen: v1.1.0")) + self.assertEqual(self.verdict(" rev: # frozen: ")[0], "placeholder") + + def test_commented_rev_line_is_checked(self): + self.assertEqual(self.verdict("# rev: v1")[0], "error") + + def test_unrelated_lines_are_ignored(self): + for text in ("name: x", " - run: echo uses", "Use `bawbel/integrations` and pin it.", ""): + self.assertIsNone(self.verdict(text), text) + + +class TestOwnActionTag(unittest.TestCase): + def test_sha_must_match_the_tag(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sha = make_repo(root) + ok = f" - uses: bawbel/integrations@{sha} # v1.1.0" + self.assertIsNone(check_pins.check_line(root, ok)) + + wrong = f" - uses: bawbel/integrations@{SHA_B} # v1.1.0" + kind, message = check_pins.check_line(root, wrong) + self.assertEqual(kind, "error") + self.assertIn(sha, message) + + def test_tag_that_is_not_in_the_checkout_fails(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + make_repo(root) + line = f" - uses: bawbel/integrations@{SHA_B} # v9.9.9" + kind, message = check_pins.check_line(root, line) + self.assertEqual(kind, "error") + self.assertIn("not in this checkout", message) + + def test_subpath_action_is_compared_with_the_tag_too(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + make_repo(root) + line = f" - uses: bawbel/integrations/gate-lint@{SHA_B} # v1.1.0" + self.assertEqual(check_pins.check_line(root, line)[0], "error") + + def test_third_party_action_is_not_compared_with_a_local_tag(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + make_repo(root) + self.assertIsNone( + check_pins.check_line(root, f" - uses: actions/checkout@{SHA_A} # v4.4.0") + ) + + +class TestRun(unittest.TestCase): + def tree(self, files): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + for name, body in files.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + return root + + def test_reports_file_and_line(self): + root = self.tree( + { + ".github/workflows/ci.yml": f"jobs:\n a:\n steps:\n - uses: actions/checkout@{SHA_A} # v4.4.0\n", + "README.md": "text\n```yaml\n - uses: actions/checkout@v4\n```\n", + } + ) + errors, placeholders = check_pins.run(root) + self.assertEqual(placeholders, []) + self.assertEqual(len(errors), 1) + self.assertTrue(errors[0].startswith("README.md:3:"), errors[0]) + + def test_files_outside_the_list_are_not_read(self): + root = self.tree({"docs/other.md": " - uses: actions/checkout@v4\n"}) + self.assertEqual(check_pins.run(root), ([], [])) + + def test_examples_are_read_recursively(self): + root = self.tree({"examples/ci/deep.yml": " - uses: actions/checkout@v4\n"}) + self.assertEqual(len(check_pins.run(root)[0]), 1) + + def test_main_exit_codes(self): + bad = self.tree({"README.md": " - uses: actions/checkout@v4\n"}) + good = self.tree({"README.md": f" - uses: actions/checkout@{SHA_A} # v4.4.0\n"}) + self.assertEqual(check_pins.main(["--root", str(bad)]), 1) + self.assertEqual(check_pins.main(["--root", str(good)]), 0) + self.assertEqual(check_pins.main(["--root", str(good / "missing")]), 2) + + def test_placeholders_do_not_fail_the_run(self): + root = self.tree({"README.md": " - uses: bawbel/integrations/gate-lint@ # \n"}) + errors, placeholders = check_pins.run(root) + self.assertEqual((errors, len(placeholders)), ([], 1)) + self.assertEqual(check_pins.main(["--root", str(root)]), 0) + + +class TestThisRepository(unittest.TestCase): + """The tree this test runs in. CI checks out with fetch-depth 0 so the tags are there.""" + + def test_every_reference_is_pinned(self): + errors, _ = check_pins.run(ROOT) + self.assertEqual(errors, []) + + +if __name__ == "__main__": + unittest.main()