From 2d8cc3eb1d67222ec7718261b4a7d2ad7aa0a970 Mon Sep 17 00:00:00 2001 From: Svilen Stefanov Date: Thu, 20 Aug 2026 00:38:46 +0200 Subject: [PATCH] fix(review): say when artifacts cannot be listed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listing artifacts needs actions: read; uploading them needs nothing. A repository whose workflow omits the permission therefore publishes a bundle on every run and reads one on none, while the log says only "No stored ... to reuse" — the same line an empty repository prints on its first run. It looks like a permanent cold cache and never explains itself. CodeBoarding-webview is in exactly that state: every review publishes both bundles and every review starts from the base. A denied listing now warns and names the permission, which is the one thing the reader needs to know. A genuinely empty result still prints the notice it printed before. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/action/fetch-state.sh | 9 ++++++++- tests/test_action_state.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/action/fetch-state.sh b/scripts/action/fetch-state.sh index c0385b2..cf16a1e 100755 --- a/scripts/action/fetch-state.sh +++ b/scripts/action/fetch-state.sh @@ -32,7 +32,14 @@ trusted='[.artifacts[]? | sort_by(.created_at) | last' selected="" expires="" rejected=0 page=1 while [ "$page" -le 10 ]; do - listing="$(api "repos/$REPOSITORY/actions/artifacts?name=$ARTIFACT_NAME&per_page=100&page=$page" 2>/dev/null || true)" + if ! listing="$(api "repos/$REPOSITORY/actions/artifacts?name=$ARTIFACT_NAME&per_page=100&page=$page" 2>/dev/null)"; then + # A denied listing and an empty one are the same silence otherwise, and the + # denial is permanent: the run publishes fine, since uploading needs no + # permission, so a repository can look like it is always cold and never + # learn why. + echo "::warning::Could not list artifacts in $REPOSITORY. Add 'actions: read' to the job's permissions to reuse previous analyses; deriving from the base for now." + exit 0 + fi [ -n "$listing" ] || break returned="$(jq -r '.artifacts | length' <<< "$listing" 2>/dev/null || echo 0)" [ "${returned:-0}" -gt 0 ] || break diff --git a/tests/test_action_state.py b/tests/test_action_state.py index 748f234..e5060fe 100644 --- a/tests/test_action_state.py +++ b/tests/test_action_state.py @@ -671,3 +671,36 @@ def test_it_uses_state_produced_by_this_repository(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue((self.root / "out").exists()) + + +class UnreadableArtifactsTests(unittest.TestCase): + """Listing needs actions: read; uploading does not. Without it a repository + publishes on every run and reads on none, which looks like a permanent miss.""" + + def test_it_says_why_when_the_listing_is_denied(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bin_dir = root / "bin" + bin_dir.mkdir() + gh = bin_dir / "gh" + gh.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") # denied + gh.chmod(0o755) + + result = subprocess.run( + [str(ROOT / "scripts" / "action" / "fetch-state.sh")], + env={ + "PATH": f"{bin_dir}:{os.environ['PATH']}", + "RUNNER_TEMP": str(root), + "REPOSITORY": "owner/repo", + "GH_HOST": "https://github.com", + "ARTIFACT_NAME": "codeboarding-base-cfg-sha", + "DEST": str(root / "out"), + }, + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("actions: read", result.stdout) + self.assertFalse((root / "out").exists())