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
41 changes: 41 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,47 @@ If you want to restrict your token so it can *only* fork repositories into your
6. Generate the token and paste it into your `~/.config/uvault/config.toml` file.
*(Note: Fine-grained tokens have implicit read access to all public repositories, which allows them to read the upstream source repository before forking it into your organization).*

**Automatic Tag Protection (GitHub):**

When `uvault sync` forks a repository into your organization, it also applies a
repository ruleset named `uvault-tags-immutable` to that vault repository. A tag
is only a durable pin if nothing can move or remove it, and by default anyone
with push access can delete a tag or force-push over it — the same failure mode
vaulting exists to prevent, relocated from the upstream repository to your vault.

The ruleset targets **all tags** and blocks three things:

| rule | why |
|---|---|
| `deletion` | a vault tag can be the last remaining reference to a commit upstream has discarded |
| `non_fast_forward` | a tag that can be rewritten is not a pin |
| `update` | `non_fast_forward` alone still permits force-pushing an existing tag |

Tag **creation** is deliberately *not* restricted: the vault must keep accepting
new tags, so blocking it would break every subsequent `uvault sync`. Blocking
updates costs nothing in exchange, because `uvault` pushes
`<sha>:refs/tags/<tag>` without `--force` and so never moves a tag that already
exists.

This is **advisory, and never blocks vaulting**. Creating a ruleset requires the
`Administration: write` permission, which is more than forking and pushing need,
so a token without it still vaults successfully — you get a warning and the tags
are left unprotected:

```
WARNING: not allowed to protect tags on 'myorg/myrepo' (creating a ruleset
needs Administration: write). Tags are vaulted but deletable.
```

Two further cases worth knowing:

* Rulesets are unavailable on some plans. On GitHub Free they require the
repository to be **public**; a private vault repository reports a 404 and the
same warning is printed.
* Repositories forked before this behaviour existed are not retrofitted — the
ruleset is applied at fork time. Re-applying it to existing vault
repositories is not yet exposed as a command.

### `uvault add`

Adds a new dependency intention directly into `[tool.uvault.sources]` without manual file editing.
Expand Down
90 changes: 90 additions & 0 deletions src/uvault/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,101 @@ def fork(self, target_org: str) -> bool:
# Wait a moment for GitHub to make the fork available for git operations
time.sleep(2)

# Protect the tags on the repository we just created. Advisory:
# a token that cannot create rulesets must still be able to fork,
# so this never changes the outcome of the fork itself.
self.ensure_tag_ruleset(mon_fork_orga.full_name)

return True
except GithubException as e:
print(f"Failed to fork GitHub repository: {e}")
return False

#: Name of the ruleset uvault manages. Stable, so re-running is idempotent.
TAG_RULESET_NAME = "uvault-tags-immutable"

def ensure_tag_ruleset(self, repo_path: str) -> bool:
"""Make the tags in ``repo_path`` immutable. Idempotent.

A vault tag is only a promise until something stops it being deleted
or moved; a plain tag can be force-pushed or removed by anyone with
push access, which is the very failure mode vaulting exists to avoid.

Deliberately takes a repository path rather than deriving one, so the
same primitive serves a per-upstream fork and a single shared vault
repository. Call it after creating a fork, or after pushing a tag.

Returns True when the ruleset is present afterwards, False when it
could not be created. **Never raises**: creating a ruleset needs
``Administration: write``, which is strictly more than forking and
pushing, so a token that cannot do it must still be able to vault.
"""
g = self._get_client(allow_anonymous=False)
if not g:
return False

try:
from github.GithubException import GithubException # type: ignore
except ImportError:
return False

base = f"/repos/{repo_path}/rulesets"
payload = {
"name": self.TAG_RULESET_NAME,
"target": "tag",
"enforcement": "active",
"conditions": {"ref_name": {"include": ["~ALL"], "exclude": []}},
# `update` as well as `non_fast_forward`: the latter alone still
# permits force-pushing an existing tag, so both are needed for a
# tag to actually be immutable.
#
# `creation` is deliberately absent. It is the obvious fourth rule
# and it would break every later sync, because the vault has to
# keep accepting new tags. Blocking `update` costs nothing by
# comparison: uvault pushes `<sha>:refs/tags/<tag>` with no `+`
# and no `--force`, so it never moves a tag that already exists.
"rules": [
{"type": "deletion"},
{"type": "non_fast_forward"},
{"type": "update"},
],
}

try:
_, existing = g.requester.requestJsonAndCheck("GET", base)
for ruleset in existing or []:
if (
ruleset.get("name") == self.TAG_RULESET_NAME
and ruleset.get("target") == "tag"
):
return True
g.requester.requestJsonAndCheck("POST", base, input=payload)
except GithubException as e:
status = getattr(e, "status", None)
if status == 403:
print(
f"WARNING: not allowed to protect tags on '{repo_path}' "
"(creating a ruleset needs Administration: write). "
"Tags are vaulted but deletable."
)
elif status == 404:
print(
f"WARNING: cannot protect tags on '{repo_path}': rulesets "
"are unavailable here. On GitHub Free they require a "
"public repository."
)
else:
print(f"WARNING: could not protect tags on '{repo_path}': {e}")
return False
except Exception as e: # noqa: BLE001 - advisory, must not break vaulting
print(f"WARNING: could not protect tags on '{repo_path}': {e}")
return False

print(
f"Tags on '{repo_path}' are now protected against deletion and force-push."
)
return True

def enrich_package_status(
self, pkg_status: "PackageStatus", ignore_labels: list[str]
) -> str | None:
Expand Down
172 changes: 172 additions & 0 deletions tests/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,175 @@ def test_user_config_token_wins_over_env(

auth = mock_github_class.call_args.kwargs.get("auth")
assert auth.token == "file-token"


@requires_github
@patch("uvault.github.read_user_config")
@patch("github.Github")
def test_ensure_tag_ruleset_creates_it(mock_github_class, mock_read_user_config):
mock_read_user_config.return_value = {"github": {"token": "t"}}
g = mock_github_class.return_value
g.requester.requestJsonAndCheck.return_value = ({}, [])

forge = GitHubForge("https://github.com/foo/bar.git")
assert forge.ensure_tag_ruleset("myorg/bar") is True

post = [
c
for c in g.requester.requestJsonAndCheck.call_args_list
if c.args and c.args[0] == "POST"
]
assert len(post) == 1
payload = post[0].kwargs["input"]
assert payload["target"] == "tag"
assert payload["enforcement"] == "active"
types = {r["type"] for r in payload["rules"]}
# `non_fast_forward` alone still allows force-pushing an existing tag, so
# `update` is required too for the tag to really be immutable.
# `creation` must never be blocked: the vault has to keep accepting tags.
assert types == {"deletion", "non_fast_forward", "update"}
assert "creation" not in types


@requires_github
@patch("uvault.github.read_user_config")
@patch("github.Github")
def test_ensure_tag_ruleset_is_idempotent(mock_github_class, mock_read_user_config):
mock_read_user_config.return_value = {"github": {"token": "t"}}
g = mock_github_class.return_value
g.requester.requestJsonAndCheck.return_value = (
{},
[{"name": GitHubForge.TAG_RULESET_NAME, "target": "tag"}],
)

forge = GitHubForge("https://github.com/foo/bar.git")
assert forge.ensure_tag_ruleset("myorg/bar") is True
# Already present: must not POST again.
assert not [
c
for c in g.requester.requestJsonAndCheck.call_args_list
if c.args and c.args[0] == "POST"
]


@requires_github
@patch("uvault.github.read_user_config")
@patch("github.Github")
def test_ensure_tag_ruleset_fails_soft_without_admin(
mock_github_class, mock_read_user_config, capsys
):
"""A token that cannot create rulesets must still be able to vault."""
mock_read_user_config.return_value = {"github": {"token": "t"}}
from github.GithubException import GithubException # type: ignore

g = mock_github_class.return_value

def side_effect(method, url, **kw):
if method == "GET":
return ({}, [])
raise GithubException(403, {"message": "Resource not accessible"}, None)

g.requester.requestJsonAndCheck.side_effect = side_effect

forge = GitHubForge("https://github.com/foo/bar.git")
# Does not raise, and says why.
assert forge.ensure_tag_ruleset("myorg/bar") is False
assert "Administration: write" in capsys.readouterr().out


@requires_github
@patch("uvault.github.read_user_config")
@patch("github.Github")
def test_fork_protects_tags_but_is_not_gated_on_it(
mock_github_class, mock_read_user_config
):
"""Forking must succeed even when the tags cannot be protected."""
mock_read_user_config.return_value = {"github": {"token": "t"}}
from github.GithubException import GithubException # type: ignore

g = mock_github_class.return_value
g.get_organization.return_value.create_fork.return_value = MagicMock(
full_name="myorg/bar", html_url="https://github.com/myorg/bar"
)
g.requester.requestJsonAndCheck.side_effect = GithubException(
403, {"message": "nope"}, None
)

forge = GitHubForge("https://github.com/foo/bar.git")
assert forge.fork("myorg") is True


@requires_github
@patch("uvault.github.read_user_config")
@patch("github.Github")
def test_ensure_tag_ruleset_never_raises_on_an_odd_response(
mock_github_class, mock_read_user_config
):
"""The promise is 'never raises' -- an unexpected payload must not escape.

Regression: an unpackable-into-two response made this raise ValueError,
which propagated out of fork() and failed the fork itself.
"""
mock_read_user_config.return_value = {"github": {"token": "t"}}
g = mock_github_class.return_value
g.requester.requestJsonAndCheck.return_value = object() # not a 2-tuple

forge = GitHubForge("https://github.com/foo/bar.git")
assert forge.ensure_tag_ruleset("myorg/bar") is False


@patch("uvault.github.read_user_config")
def test_ensure_tag_ruleset_without_a_token(mock_read_user_config):
mock_read_user_config.return_value = {}
forge = GitHubForge("https://github.com/foo/bar.git")
assert forge.ensure_tag_ruleset("myorg/bar") is False


@requires_github
@patch("uvault.github.read_user_config")
def test_ensure_tag_ruleset_without_pygithub(mock_read_user_config):
mock_read_user_config.return_value = {"github": {"token": "t"}}
forge = GitHubForge("https://github.com/foo/bar.git")
with patch.dict(
"sys.modules", {"github": MagicMock(), "github.GithubException": None}
):
assert forge.ensure_tag_ruleset("myorg/bar") is False


@requires_github
@patch("uvault.github.read_user_config")
@patch("github.Github")
def test_ensure_tag_ruleset_when_rulesets_are_unavailable(
mock_github_class, mock_read_user_config, capsys
):
"""404 is what a plan without rulesets looks like."""
mock_read_user_config.return_value = {"github": {"token": "t"}}
from github.GithubException import GithubException # type: ignore

g = mock_github_class.return_value
g.requester.requestJsonAndCheck.side_effect = GithubException(
404, {"message": "Not Found"}, None
)

forge = GitHubForge("https://github.com/foo/bar.git")
assert forge.ensure_tag_ruleset("myorg/bar") is False
assert "public repository" in capsys.readouterr().out


@requires_github
@patch("uvault.github.read_user_config")
@patch("github.Github")
def test_ensure_tag_ruleset_on_an_unexpected_api_error(
mock_github_class, mock_read_user_config, capsys
):
mock_read_user_config.return_value = {"github": {"token": "t"}}
from github.GithubException import GithubException # type: ignore

g = mock_github_class.return_value
g.requester.requestJsonAndCheck.side_effect = GithubException(
500, {"message": "boom"}, None
)

forge = GitHubForge("https://github.com/foo/bar.git")
assert forge.ensure_tag_ruleset("myorg/bar") is False
assert "could not protect tags" in capsys.readouterr().out
Loading