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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## v1.0.3 - 2026-09-08

- APT publication now checks Git access and requires a successful state reservation instead of relying on repository role flags that reject GitHub Actions installation tokens.
- Includes the settings, Parakeet discovery, GNOME shortcut, and bandlimited audio fixes from v1.0.2.

## v1.0.2 - 2026-09-08

- Settings download progress no longer outruns backend snapshot revisions; completed operations release controls and ignore late progress events.
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ edition = "2021"
license = "MIT"
repository = "https://github.com/ddv1982/echo"
rust-version = "1.89"
version = "1.0.2"
version = "1.0.3"

[workspace.lints.rust]
unsafe_code = "forbid"
Expand Down
1 change: 1 addition & 0 deletions packaging/io.github.ddv1982.echo.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<category>Utility</category>
</categories>
<releases>
<release version="1.0.3" date="2026-09-08" />
<release version="1.0.2" date="2026-09-08" />
<release version="1.0.1" date="2026-09-08" />
<release version="1.0.0" date="2026-09-07" />
Expand Down
11 changes: 8 additions & 3 deletions scripts/guard_apt_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,15 @@ def api(self, path: str, data=None, *, method=None, missing_ok=False):
raise

def read(self) -> dict | None:
# A ref 404 is meaningful only after verifying access to the repository.
# Repository role booleans do not describe an installation token's
# contents permission. Probe Git access before treating a ref 404 as
# absent; the mandatory reservation write enforces write permission.
# No deployment is permitted when that write fails.
repository = self.api("")
if repository["full_name"].lower() != self.repository.lower() or not repository["permissions"]["push"]:
raise ValueError("cannot authenticate publication state repository write access")
if repository["full_name"].lower() != self.repository.lower():
raise ValueError("publication state repository identity mismatch")
branch = urllib.parse.quote(repository["default_branch"], safe="")
self.api(f"/git/ref/heads/{branch}")
ref = self.api(f"/git/ref/heads/{STATE_BRANCH}", missing_ok=True)
if ref is None:
return None
Expand Down
48 changes: 48 additions & 0 deletions scripts/test_apt_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import copy
import json
import pathlib
import shutil
import subprocess
Expand Down Expand Up @@ -128,6 +129,53 @@ def test_state_api_errors_are_not_first_publication(self):
with self.assertRaises(urllib.error.URLError):
publication.guard(identity(), state, lambda: None, allow_first_publication=True)

def test_installation_token_role_flags_do_not_block_publication(self):
state = publication.GitHubState("owner/repo", "fixture-token")
# GitHub installation tokens can report push=false despite contents:write.
stored = {}

def api(path, data=None, **_kwargs):
if path == "":
return {"full_name": "owner/repo", "default_branch": "main", "permissions": {"push": False}}
if path == "/git/ref/heads/main":
return {"object": {"type": "commit", "sha": "main-sha"}}
if path == "/git/trees":
stored["candidate"] = json.loads(data["tree"][0]["content"])
return {"sha": "tree-sha"}
if path == "/git/commits":
return {"sha": "state-sha"}
if path == "/git/refs":
stored["published"] = stored["candidate"]
return {"ref": data["ref"]}
return None

with patch.object(state, "api", side_effect=api):
publication.guard(identity("1.0.1"), state, lambda: identity(), allow_first_publication=False)
self.assertEqual(stored["published"], identity("1.0.1"))

def test_git_access_failure_is_not_missing_state(self):
for code in (403, 404):
state = publication.GitHubState("owner/repo", "fixture-token")
replies = [
{"full_name": "owner/repo", "default_branch": "main"},
urllib.error.HTTPError("https://api.github.com/git/ref", code, "denied", {}, None),
]
with self.subTest(code=code), patch.object(state, "api", side_effect=replies):
with self.assertRaises(urllib.error.HTTPError):
publication.guard(identity(), state, lambda: None, allow_first_publication=True)

def test_reservation_write_denial_blocks_publication(self):
state = publication.GitHubState("owner/repo", "fixture-token")
replies = [
{"full_name": "owner/repo", "default_branch": "main"},
{"object": {"type": "commit", "sha": "main-sha"}},
None,
urllib.error.HTTPError("https://api.github.com/git/trees", 403, "denied", {}, None),
]
with patch.object(state, "api", side_effect=replies):
with self.assertRaises(urllib.error.HTTPError):
publication.guard(identity("1.0.1"), state, lambda: identity(), allow_first_publication=False)


class SignedIdentityTests(unittest.TestCase):
@classmethod
Expand Down