diff --git a/.github/tests/starchart_refresh_contract_test.py b/.github/tests/starchart_refresh_contract_test.py index 8962cf7..1866aca 100644 --- a/.github/tests/starchart_refresh_contract_test.py +++ b/.github/tests/starchart_refresh_contract_test.py @@ -210,6 +210,52 @@ def test_the_suppression_trap_is_documented_not_just_avoided(self): # preference: wired that way it lints clean and never runs. self.assertIn("refreshes nothing", workflow) + def test_the_dispatch_snippet_carries_the_permission_it_needs(self): + """The snippet shipped without `actions: write` and portwing's first + real cut died on HTTP 403. Creating a dispatch is an Actions API + write, and `contents: write` does not imply it, so a reader who + copies the snippet and reasons about permissions from the commit it + performs gets it wrong. The scope has to be IN the snippet, not + described somewhere below it.""" + workflow = self.read_workflow() + + snippet = workflow.split("# - name: Dispatch starchart refresh", 1)[1] + snippet = snippet.split("gh workflow run", 1)[0] + self.assertIn("actions: write", snippet) + + # And the reason, so nobody trims it back out as redundant. + self.assertIn("does not imply", workflow) + self.assertIn("403", workflow) + + def test_the_silent_dispatch_claim_is_recorded_as_refuted(self): + """`workflow_dispatch` being exempt from GITHUB_TOKEN suppression is + load-bearing for every cut-dispatched caller in the org. It was + reported as false on 2026-08-21. Leaving that unrecorded means the + next agent re-derives the doubt and rips out a working trigger, so + the evidence lives here.""" + workflow = self.read_workflow() + + self.assertIn("portkey-admin-mcp", workflow) + self.assertIn("github-actions[bot]", workflow) + + def test_the_tag_push_alternative_is_documented_with_its_own_trap(self): + """A repo that would rather not widen a PAT scope has a second + working trigger. Naming it without naming the assertion is how the + dead `release:` trigger survives next to a live tag trigger, since + the two read as interchangeable.""" + workflow = self.read_workflow() + + self.assertIn('tags: ["v*"]', workflow) + # Flattened: the comment wraps, and an assertion that a phrase sits + # on one line pins the line width rather than the claim. + prose = " ".join( + line.lstrip("#").strip() + for line in workflow.split("\n") + if line.startswith("#") + ) + self.assertIn("assert BOTH that the tag trigger is present", prose) + self.assertIn("`release:` is absent", prose) + def test_the_embedded_renderer_names_its_source(self): """The same renderer exists here and in ops. Hand-copying is how they drift, so the block is generated and says so.""" diff --git a/.github/workflows/starchart-refresh.yml b/.github/workflows/starchart-refresh.yml index e593394..41b9d0a 100644 --- a/.github/workflows/starchart-refresh.yml +++ b/.github/workflows/starchart-refresh.yml @@ -35,6 +35,8 @@ name: Star Chart Refresh # The release-cut workflow dispatches this one instead: # # - name: Dispatch starchart refresh +# permissions: +# actions: write # REQUIRED. contents: write does not imply it. # env: # GH_TOKEN: ${{ github.token }} # run: gh workflow run starchart.yml --ref "$BRANCH" @@ -45,10 +47,38 @@ name: Star Chart Refresh # `pull_request` with opened/synchronize/reopened creates a run in an # approval-required state rather than being suppressed outright — but they # are the two that fire unattended, which is what a release cut needs. +# # The dispatch step must fail loudly rather than `|| true`: by the time it # runs the release is already published, so a swallowed error is the same # silent-success shape this whole workflow exists to remove. # +# `actions: write` was missing from this snippet until 2026-08-21 and it is +# the difference between a working cut and a dead one. Creating a workflow +# dispatch is an Actions API write, so a job that can commit still cannot +# dispatch. portwing v0.9.7 shipped this step as written and the first real +# cut failed with `HTTP 403: Resource not accessible by personal access +# token`, because it used RELEASE_PAT, which is Contents RW. A PAT needs the +# scope too, not just the job. +# +# The failure is at least loud. It was reported as a silent no-op — the +# dispatch succeeding and creating no run — which would have made +# `${{ github.token }}` unusable here. That does not hold: +# portkey-admin-mcp's `auto-tag.yml` dispatches `release.yml` with +# `${{ github.token }}` and `permissions: {contents: write, actions: write}`, +# and there are four `github-actions[bot]`-actored `workflow_dispatch` runs +# on record between 2026-08-04 and 2026-08-10 with real success and failure +# conclusions. Suppression and a missing scope look similar and are not: one +# is fixed by adding the scope, the other cannot be fixed. GitHub's +# events-that-trigger-workflows reference says it outright: "With the +# exception of `workflow_dispatch` and `repository_dispatch`, other +# `GITHUB_TOKEN`-triggered events do not create workflow runs at all." +# +# `on: push: tags: ["v*"]` is the other working trigger and needs no new +# scope anywhere, because the cut already pushes the tag with a PAT so that +# downstream workflows fire. portwing moved to it in #190. If you take that +# route, assert BOTH that the tag trigger is present and that `release:` is +# absent — they look interchangeable and only one of them runs. +# # Prefer dispatching BEFORE the tag is cut where the flow allows it, so the # released README ships the chart it claims to. Dispatching after publish is # an accepted tradeoff — the chart then lands on the dev branch and main's