Skip to content

Commit ead4933

Browse files
committed
ci(aur): require arming before an unattended publish
`schedule` fires every six hours off the default branch and set publish=true unconditionally. Merging this workflow was therefore enough to make mcpp start writing to the AUR on its own, within six hours, on a path that had never completed a real push — only a makepkg --verifysource dry-run. Merging is a decision about code; publishing to a third-party service is a decision about the outside world, and they should not be the same act. Both automatic triggers now plan and report but withhold the push unless the repository variable AUR_AUTOPUBLISH is exactly "true", so a typo fails closed. Dry runs still validate payloads, render .SRCINFO, query the AUR and print the diff — only the push is withheld, so nothing is lost but the surprise. workflow_dispatch keeps its explicit per-run switch, which is how the first watched publish is meant to happen, and unsetting the variable is a kill switch that needs no revert. The contract test runs the workflow's own decision shell rather than a paraphrase of it, so the guarantee cannot drift from the YAML.
1 parent 1c3078e commit ead4933

3 files changed

Lines changed: 118 additions & 8 deletions

File tree

.github/workflows/aur-publish.yml

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ jobs:
5757
env:
5858
TRIGGER: ${{ github.event_name }}
5959
MANUAL_PUBLISH: ${{ inputs.publish }}
60+
# Repository variable, absent until a human has watched one publish
61+
# succeed. See "Arming the automatic triggers" in scripts/aur/README.md.
62+
AUTOPUBLISH: ${{ vars.AUR_AUTOPUBLISH }}
6063
run: |
6164
args=(
6265
--trigger "$TRIGGER"
@@ -66,11 +69,27 @@ jobs:
6669
[[ -z "$REQUESTED_TAG" ]] || args+=(--tag "$REQUESTED_TAG")
6770
python3 scripts/aur/reconcile_mcpp_bin.py "${args[@]}"
6871
69-
if [[ "$TRIGGER" == workflow_run || "$TRIGGER" == schedule ]]; then
70-
publish=true
71-
else
72-
publish=${MANUAL_PUBLISH:-false}
73-
fi
72+
# An unattended push to a third-party service must be ARMED, not
73+
# inherited from a merge. `schedule` fires every six hours off the
74+
# default branch, so merging this workflow used to be enough to make
75+
# mcpp start writing to the AUR on its own — before anyone had seen
76+
# the reconciler complete a real push even once. Both automatic
77+
# triggers therefore plan-and-report until AUR_AUTOPUBLISH is set;
78+
# `workflow_dispatch` keeps its explicit per-run switch, which is how
79+
# that first push is meant to happen.
80+
case "$TRIGGER" in
81+
workflow_run | schedule)
82+
if [[ "${AUTOPUBLISH:-}" == "true" ]]; then
83+
publish=true
84+
else
85+
publish=false
86+
echo "::notice::AUR_AUTOPUBLISH is not set — reporting the desired state without publishing."
87+
fi
88+
;;
89+
*)
90+
publish=${MANUAL_PUBLISH:-false}
91+
;;
92+
esac
7493
echo "needs_publish=$(jq -r '.needs_publish' "$RUNNER_TEMP/aur-plan.json")" >> "$GITHUB_OUTPUT"
7594
echo "publish=$publish" >> "$GITHUB_OUTPUT"
7695

scripts/aur/README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,11 @@ mcpp --version
128128

129129
[`.github/workflows/aur-publish.yml`](../../.github/workflows/aur-publish.yml)
130130
reconciles `mcpp-bin` only. It runs after a successful `release` workflow, every
131-
six hours to recover from transient AUR outages, and on manual dispatch. Event
132-
and schedule runs publish automatically; manual dispatch defaults to dry-run
133-
and requires `publish=true` to push.
131+
six hours to recover from transient AUR outages, and on manual dispatch. Manual
132+
dispatch defaults to dry-run and requires `publish=true` to push; the two
133+
automatic triggers plan and report but do **not** push until the repository
134+
variable `AUR_AUTOPUBLISH` is set — see
135+
[Arming the automatic triggers](#arming-the-automatic-triggers).
134136

135137
Every trigger follows the same state machine:
136138

@@ -183,6 +185,32 @@ The server host key is checked against the vendored ED25519 key sourced from
183185
Arch Linux's infrastructure repository; the workflow never uses
184186
`ssh-keyscan` as a trust decision.
185187

188+
### Arming the automatic triggers
189+
190+
`schedule` fires every six hours off the default branch. That means merging
191+
this workflow is, by itself, enough to start writing to a third-party service
192+
unattended — potentially before anyone has watched the reconciler complete a
193+
real push even once. Merging is a decision about code; publishing to the AUR is
194+
a decision about the outside world, and the two should not be the same act.
195+
196+
So both automatic triggers (`workflow_run` after a release, and `schedule`)
197+
stop after the plan-and-report step unless the repository variable
198+
`AUR_AUTOPUBLISH` is set to `true`. Dry runs still validate payloads, render
199+
`.SRCINFO`, query the AUR and print the exact diff, so the reporting value is
200+
unchanged — only the push is withheld.
201+
202+
To arm it, once:
203+
204+
1. Run the workflow manually with `publish=false` and read the summary: it must
205+
show the intended version and a clean diff.
206+
2. Run it manually with `publish=true` and confirm the push, the AUR RPC row,
207+
and a clean install in an Arch container.
208+
3. Only then set *Settings → Secrets and variables → Actions → Variables →*
209+
`AUR_AUTOPUBLISH = true`.
210+
211+
Unsetting the variable is the kill switch: automatic runs immediately fall back
212+
to reporting without publishing, with no code change and no revert.
213+
186214
### First publish
187215

188216
The reconciler treats `mcpp-bin` as a known existing package. A failed clone or

tests/scripts/test_aur_reconcile.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import hashlib
77
import importlib.util
88
import json
9+
import os
10+
import re
911
import subprocess
1012
import sys
1113
import tempfile
@@ -322,6 +324,67 @@ def test_publish_uses_public_speak_agent_identity(self) -> None:
322324
"speak-agent@users.noreply.github.com",
323325
)
324326

327+
def _publish_decision(
328+
self,
329+
trigger: str,
330+
autopublish: str = "",
331+
manual: str = "",
332+
) -> str:
333+
"""Run the workflow's own publish-decision shell, not a paraphrase of it."""
334+
workflow = (
335+
REPO_ROOT / ".github" / "workflows" / "aur-publish.yml"
336+
).read_text(encoding="utf-8")
337+
match = re.search(r'^\s*case "\$TRIGGER" in$.*?^\s*esac$',
338+
workflow, re.MULTILINE | re.DOTALL)
339+
self.assertIsNotNone(match, "publish decision block not found")
340+
# The block also emits a `::notice::` line, so mark the value rather
341+
# than reading whatever happens to be on stdout.
342+
script = f'{match.group(0)}\nprintf "<publish>%s</publish>" "$publish"'
343+
result = subprocess.run(
344+
["bash", "-c", script],
345+
check=False,
346+
capture_output=True,
347+
text=True,
348+
env={
349+
"PATH": os.environ.get("PATH", ""),
350+
"TRIGGER": trigger,
351+
"AUTOPUBLISH": autopublish,
352+
"MANUAL_PUBLISH": manual,
353+
},
354+
)
355+
self.assertEqual(result.returncode, 0, result.stderr)
356+
value = re.search(r"<publish>(.*?)</publish>", result.stdout)
357+
self.assertIsNotNone(value, result.stdout)
358+
return value.group(1)
359+
360+
def test_automatic_triggers_do_not_publish_until_armed(self) -> None:
361+
# Merging a workflow is a decision about code. Pushing to the AUR is a
362+
# decision about the outside world. `schedule` runs every six hours off
363+
# the default branch, so without this gate the two are the same act:
364+
# merge, wait six hours, and mcpp has written to a third-party service
365+
# with nobody watching — on a path that had never completed a real push.
366+
for trigger in ("workflow_run", "schedule"):
367+
with self.subTest(trigger=trigger, armed=False):
368+
self.assertEqual(self._publish_decision(trigger), "false")
369+
with self.subTest(trigger=trigger, armed=True):
370+
self.assertEqual(
371+
self._publish_decision(trigger, autopublish="true"), "true")
372+
# Anything other than an exact "true" leaves it disarmed, so a typo
373+
# in the repository variable fails closed.
374+
with self.subTest(trigger=trigger, armed="typo"):
375+
self.assertEqual(
376+
self._publish_decision(trigger, autopublish="yes"), "false")
377+
378+
# Manual dispatch keeps its explicit per-run switch: that is how the
379+
# first, watched publish is meant to happen.
380+
self.assertEqual(self._publish_decision("workflow_dispatch"), "false")
381+
self.assertEqual(
382+
self._publish_decision("workflow_dispatch", manual="true"), "true")
383+
# …and arming the automatic triggers must not silently arm dispatch.
384+
self.assertEqual(
385+
self._publish_decision("workflow_dispatch", autopublish="true"),
386+
"false")
387+
325388
def test_workflow_uses_pinned_host_key_and_recovery_triggers(self) -> None:
326389
workflow = (
327390
REPO_ROOT / ".github" / "workflows" / "aur-publish.yml"

0 commit comments

Comments
 (0)