Skip to content

Commit 2cd71bf

Browse files
fix(release): await completed beta qualification
Trigger automatic beta promotion only from a completed, trusted qualification run and require explicit Stable confirmation for every pointer mutation. Tests: actionlint desktop_qualify_beta.yml desktop_promote_beta.yml desktop_promote_prod.yml; python3 .github/scripts/test_desktop_qualification_dispatch.py; python3 .github/scripts/test_stable_promotion_verifiers.py; python3 .github/scripts/test_check_release_process_guards.py; make preflight (blocked only by inherited c5 changelog false-positive). Failure-Class: FC-split-mutation-authority
1 parent 3abde2e commit 2cd71bf

11 files changed

Lines changed: 207 additions & 40 deletions

.github/checks-manifest.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ checks:
3636
reason: "advisory desktop release evidence and drift report"
3737
- id: desktop-qualification-immutable-dispatch
3838
command: ["python3", ".github/scripts/test_desktop_qualification_dispatch.py"]
39-
triggers: ["codemagic.yaml", ".github/workflows/desktop_qualify_beta.yml", ".github/scripts/test_desktop_qualification_dispatch.py", ".github/checks-manifest.yaml"]
39+
triggers: ["codemagic.yaml", ".github/workflows/desktop_qualify_beta.yml", ".github/workflows/desktop_promote_beta.yml", ".github/scripts/test_desktop_qualification_dispatch.py", ".github/checks-manifest.yaml"]
4040
lanes: ["local", "ci"]
4141
reason: "candidate qualification must dispatch and rebuild at one immutable candidate tag SHA"
4242
- id: desktop-changelog-io-tests
@@ -372,12 +372,12 @@ checks:
372372
reason: "#10163 mutation-tests production-family mobile API routing"
373373
- id: stable-pointer-precondition-cli
374374
command: ["python3", ".github/scripts/check_stable_pointer_precondition.py", "--help"]
375-
triggers: [".github/workflows/desktop_promote_prod.yml", ".github/scripts/check_stable_pointer_precondition.py", ".github/scripts/verify_stable_appcast.py", ".github/scripts/test_stable_promotion_verifiers.py", ".github/checks-manifest.yaml"]
375+
triggers: [".github/workflows/desktop_promote_prod.yml", ".github/scripts/check_stable_pointer_precondition.py", ".github/scripts/require_stable_promotion_confirmation.py", ".github/scripts/verify_stable_appcast.py", ".github/scripts/test_stable_promotion_verifiers.py", ".github/checks-manifest.yaml"]
376376
lanes: ["local", "ci"]
377377
reason: "#10163 keeps Stable retry acknowledgement and appcast verification executable"
378378
- id: stable-pointer-precondition-fixtures
379379
command: ["python3", ".github/scripts/test_stable_promotion_verifiers.py"]
380-
triggers: [".github/workflows/desktop_promote_prod.yml", ".github/scripts/check_stable_pointer_precondition.py", ".github/scripts/verify_stable_appcast.py", ".github/scripts/test_stable_promotion_verifiers.py", ".github/checks-manifest.yaml"]
380+
triggers: [".github/workflows/desktop_promote_prod.yml", ".github/scripts/check_stable_pointer_precondition.py", ".github/scripts/require_stable_promotion_confirmation.py", ".github/scripts/verify_stable_appcast.py", ".github/scripts/test_stable_promotion_verifiers.py", ".github/checks-manifest.yaml"]
381381
lanes: ["local", "ci"]
382382
reason: "#10163 mutation-sensitive Stable retry and default-channel feed fixtures"
383383
- id: stable-static-publication-fixtures

.github/scripts/check-desktop-prod-promotion-policy.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
"on:\n workflow_dispatch:",
1010
"confirm:",
1111
"promote-stable",
12+
"Require explicit Stable confirmation",
13+
"require_stable_promotion_confirmation.py",
14+
'--operation "$OPERATION" --confirm "$CONFIRM"',
1215
"operation:",
1316
"expected_current_release_id:",
1417
"expected_generation:",
@@ -57,6 +60,10 @@ def validate(text: str) -> list[str]:
5760
errors.append(f"stable pointer promotion must not contain backend deployment or bypass path: {forbidden}")
5861
if "\n push:" in text or "\n schedule:" in text or "\n release:" in text:
5962
errors.append("stable pointer promotion must remain manual-only")
63+
confirmation = text.find("Require explicit Stable confirmation")
64+
for later_step in ("Checkout promotion controls", "Generate Omi Bot token", "Google Auth"):
65+
if confirmation == -1 or confirmation > text.find(later_step):
66+
errors.append(f"Stable confirmation must fail closed before {later_step}")
6067
order = [text.find(fragment) for fragment in ORDERED_STEPS]
6168
if -1 in order or order != sorted(order):
6269
errors.append("stable promotion must fetch and verify retained identity before pointer mutation, then bridge and verify")

.github/scripts/check-release-process-guards.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,6 @@ def check_desktop_qualification_runner() -> list[str]:
299299
"check-desktop-auto-beta-candidate.py",
300300
"--automatic",
301301
"--no-promote",
302-
"desktop_promote_beta.yml",
303302
"actions/create-github-app-token@v3",
304303
"desktop-beta-qualification-${{ inputs.release_tag }}",
305304
"cancel-in-progress: false",
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env python3
2+
"""Fail closed unless a Stable pointer mutation has explicit operator intent."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
8+
9+
CONFIRMATION = "promote-stable"
10+
OPERATIONS = {"promote", "repoint"}
11+
12+
13+
def validate(*, operation: str, confirm: str) -> None:
14+
if operation not in OPERATIONS:
15+
raise ValueError(f"unsupported Stable operation: {operation}")
16+
if confirm != CONFIRMATION:
17+
raise ValueError(f"confirm must be exactly: {CONFIRMATION}")
18+
19+
20+
def main() -> int:
21+
parser = argparse.ArgumentParser()
22+
parser.add_argument("--operation", required=True)
23+
parser.add_argument("--confirm", required=True)
24+
args = parser.parse_args()
25+
try:
26+
validate(operation=args.operation, confirm=args.confirm)
27+
except ValueError as error:
28+
parser.error(str(error))
29+
return 0
30+
31+
32+
if __name__ == "__main__":
33+
raise SystemExit(main())

.github/scripts/test_desktop_qualification_dispatch.py

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
ROOT = Path(__file__).resolve().parents[2]
1212
CODEMAGIC = ROOT / "codemagic.yaml"
1313
QUALIFIER = ROOT / ".github/workflows/desktop_qualify_beta.yml"
14+
BETA_PROMOTION = ROOT / ".github/workflows/desktop_promote_beta.yml"
1415

1516

1617
def validate(root: Path) -> list[str]:
1718
codemagic = (root / "codemagic.yaml").read_text(encoding="utf-8")
1819
qualifier = (root / ".github/workflows/desktop_qualify_beta.yml").read_text(encoding="utf-8")
20+
beta_promotion = (root / ".github/workflows/desktop_promote_beta.yml").read_text(encoding="utf-8")
1921
errors: list[str] = []
2022
dispatch = codemagic.split("gh workflow run desktop_qualify_beta.yml", 1)[1].split("then", 1)[0]
2123
if '--ref "$CM_TAG"' not in dispatch:
@@ -26,27 +28,110 @@ def validate(root: Path) -> list[str]:
2628
errors.append("qualification must not check out mutable main")
2729
if "CHECKOUT_SHA=$(git rev-parse HEAD)" not in qualifier or "EVENT_SHA: ${{ github.sha }}" not in qualifier:
2830
errors.append("candidate validation must compare the fetched candidate tag against the checked-out event SHA")
31+
if "gh workflow run desktop_promote_beta.yml" in qualifier:
32+
errors.append("qualification must not dispatch beta promotion before its run has completed")
33+
34+
for fragment, message in (
35+
(
36+
' workflow_run:\n workflows: ["Qualify Desktop Beta Candidate"]\n types: [completed]',
37+
"automatic beta promotion must be triggered only by completed qualification runs",
38+
),
39+
(
40+
"github.event.workflow_run.conclusion == 'success'",
41+
"automatic beta promotion must require a successful qualification conclusion",
42+
),
43+
(
44+
"github.event.workflow_run.event == 'workflow_dispatch'",
45+
"automatic beta promotion must require the trusted workflow_dispatch qualifier",
46+
),
47+
(
48+
"github.event.workflow_run.repository.full_name == github.repository",
49+
"automatic beta promotion must require a same-repository workflow run",
50+
),
51+
(
52+
"github.event.workflow_run.head_repository.full_name == github.repository",
53+
"automatic beta promotion must reject fork-sourced qualification",
54+
),
55+
(
56+
"github.event.workflow_run.path == '.github/workflows/desktop_qualify_beta.yml'",
57+
"automatic beta promotion must require the desktop qualification workflow path",
58+
),
59+
(
60+
"RELEASE_TAG: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || inputs.release_tag }}",
61+
"automatic beta promotion must derive its release tag from the completed qualifier",
62+
),
63+
(
64+
"QUALIFICATION_RUN_ID: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || inputs.qualification_run_id }}",
65+
"automatic beta promotion must use the completed qualifier run ID",
66+
),
67+
(
68+
'test "$(jq -r .head_branch <<<"$run")" = "$RELEASE_TAG"',
69+
"automatic beta promotion must bind qualification evidence to the release tag",
70+
),
71+
(
72+
'test "$(jq -r .head_sha <<<"$run")" = "$TARGET_SHA"',
73+
"automatic beta promotion must bind qualification evidence to the exact tag SHA",
74+
),
75+
(
76+
'test "$QUALIFICATION_RUN_ID" = "$QUALIFIER_EVENT_RUN_ID"',
77+
"automatic beta promotion must use the triggering completed qualification run",
78+
),
79+
(
80+
'test "$QUALIFIER_EVENT_HEAD_SHA" = "$TARGET_SHA"',
81+
"automatic beta promotion must bind the triggering qualifier SHA to the release tag",
82+
),
83+
):
84+
if fragment not in beta_promotion:
85+
errors.append(message)
2986
return errors
3087

3188

3289
class DesktopQualificationDispatchTests(unittest.TestCase):
33-
def test_current_configuration_uses_the_candidate_tag_and_event_sha(self) -> None:
90+
def test_current_configuration_uses_the_candidate_tag_and_completed_qualifier(self) -> None:
3491
self.assertEqual(validate(ROOT), [])
3592

3693
def test_mutations_drop_tag_ref_or_reintroduce_mutable_checkout(self) -> None:
3794
codemagic = CODEMAGIC.read_text(encoding="utf-8")
3895
qualifier = QUALIFIER.read_text(encoding="utf-8")
96+
beta_promotion = BETA_PROMOTION.read_text(encoding="utf-8")
3997
mutations = (
40-
(codemagic.replace('--ref "$CM_TAG" \\\n ', "", 1), qualifier),
41-
(codemagic, qualifier.replace("ref: ${{ github.sha }}", "ref: main", 1)),
42-
(codemagic, qualifier.replace("CHECKOUT_SHA=$(git rev-parse HEAD)", "CHECKOUT_SHA=$(git rev-parse \"$RELEASE_TAG\")", 1)),
98+
(codemagic.replace('--ref "$CM_TAG" \\\n ', "", 1), qualifier, beta_promotion),
99+
(codemagic, qualifier.replace("ref: ${{ github.sha }}", "ref: main", 1), beta_promotion),
100+
(
101+
codemagic,
102+
qualifier.replace("CHECKOUT_SHA=$(git rev-parse HEAD)", "CHECKOUT_SHA=$(git rev-parse \"$RELEASE_TAG\")", 1),
103+
beta_promotion,
104+
),
105+
(codemagic, qualifier + '\n gh workflow run desktop_promote_beta.yml\n', beta_promotion),
106+
(
107+
codemagic,
108+
qualifier,
109+
beta_promotion.replace("github.event.workflow_run.conclusion == 'success'", "github.event.workflow_run.conclusion == 'failure'", 1),
110+
),
111+
(
112+
codemagic,
113+
qualifier,
114+
beta_promotion.replace(
115+
"github.event.workflow_run.head_repository.full_name == github.repository",
116+
"github.event.workflow_run.head_repository.full_name == 'attacker/omi'",
117+
1,
118+
),
119+
),
120+
(
121+
codemagic,
122+
qualifier,
123+
beta_promotion.replace('test "$(jq -r .head_sha <<<"$run")" = "$TARGET_SHA"', 'test "$(jq -r .head_sha <<<"$run")" = main', 1),
124+
),
43125
)
44-
for changed_codemagic, changed_qualifier in mutations:
126+
for changed_codemagic, changed_qualifier, changed_beta_promotion in mutations:
45127
with self.subTest(mutation=changed_codemagic != codemagic), tempfile.TemporaryDirectory() as directory:
46128
root = Path(directory)
47129
(root / ".github/workflows").mkdir(parents=True)
48130
(root / "codemagic.yaml").write_text(changed_codemagic, encoding="utf-8")
49131
(root / ".github/workflows/desktop_qualify_beta.yml").write_text(changed_qualifier, encoding="utf-8")
132+
(root / ".github/workflows/desktop_promote_beta.yml").write_text(
133+
changed_beta_promotion, encoding="utf-8"
134+
)
50135
self.assertTrue(validate(root))
51136

52137

.github/scripts/test_stable_promotion_verifiers.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,37 @@ def _load(name: str):
2020

2121
APPCAST = _load("verify_stable_appcast.py")
2222
POINTER = _load("check_stable_pointer_precondition.py")
23+
CONFIRMATION = _load("require_stable_promotion_confirmation.py")
24+
POLICY = _load("check-desktop-prod-promotion-policy.py")
2325

2426

2527
def _fields(release_id: str, generation: int) -> dict:
2628
return {"release_id": {"stringValue": release_id}, "generation": {"integerValue": str(generation)}}
2729

2830

2931
class StablePromotionVerifierTests(unittest.TestCase):
32+
def test_confirmation_rejects_wrong_or_missing_token_for_every_stable_mutation(self):
33+
for operation in ("promote", "repoint"):
34+
for confirm in ("", "promote-beta"):
35+
with self.subTest(operation=operation, confirm=confirm or "missing"):
36+
with self.assertRaisesRegex(ValueError, "confirm must be exactly"):
37+
CONFIRMATION.validate(operation=operation, confirm=confirm)
38+
CONFIRMATION.validate(operation=operation, confirm="promote-stable")
39+
40+
def test_policy_requires_confirmation_before_authentication_or_mutation(self):
41+
original = (ROOT / POLICY.WORKFLOW).read_text(encoding="utf-8")
42+
mutations = (
43+
original.replace(" - name: Require explicit Stable confirmation\n", " - name: Confirmation moved too late\n", 1),
44+
original.replace("--operation \"$OPERATION\" --confirm \"$CONFIRM\"", "--operation \"$OPERATION\" --confirm promote-beta", 1),
45+
)
46+
for changed in mutations:
47+
with self.subTest(mutation=changed != original), tempfile.TemporaryDirectory() as directory:
48+
root = Path(directory)
49+
workflow = root / POLICY.WORKFLOW
50+
workflow.parent.mkdir(parents=True)
51+
workflow.write_text(changed, encoding="utf-8")
52+
self.assertTrue(POLICY.validate(workflow.read_text(encoding="utf-8")))
53+
3054
def test_lost_response_retry_accepts_only_the_expected_next_generation(self):
3155
POINTER.verify(
3256
beta=_fields("target", 4),

.github/workflows/desktop_promote_beta.yml

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
name: Promote Qualified Desktop Beta
22

33
on:
4+
workflow_run:
5+
workflows: ["Qualify Desktop Beta Candidate"]
6+
types: [completed]
47
workflow_dispatch:
58
inputs:
69
release_tag:
@@ -25,8 +28,23 @@ concurrency:
2528
group: desktop-beta-promotion
2629
cancel-in-progress: false
2730

31+
env:
32+
RELEASE_TAG: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || inputs.release_tag }}
33+
QUALIFICATION_RUN_ID: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || inputs.qualification_run_id }}
34+
AUTOMATIC_REQUEST: ${{ github.event_name == 'workflow_run' || inputs.automatic }}
35+
QUALIFIER_EVENT_RUN_ID: ${{ github.event.workflow_run.id }}
36+
QUALIFIER_EVENT_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
37+
2838
jobs:
2939
promote:
40+
if: >-
41+
github.event_name == 'workflow_dispatch' ||
42+
(github.event.workflow_run.conclusion == 'success' &&
43+
github.event.workflow_run.event == 'workflow_dispatch' &&
44+
github.event.workflow_run.repository.full_name == github.repository &&
45+
github.event.workflow_run.head_repository.full_name == github.repository &&
46+
github.event.workflow_run.path == '.github/workflows/desktop_qualify_beta.yml' &&
47+
github.event.workflow_run.name == 'Qualify Desktop Beta Candidate')
3048
environment: prod
3149
runs-on: ubuntu-latest
3250
steps:
@@ -44,10 +62,9 @@ jobs:
4462
private-key: ${{ secrets.OMI_BOT_PRIVATE_KEY }}
4563

4664
- name: Validate automatic beta request
47-
if: ${{ inputs.automatic }}
65+
if: ${{ env.AUTOMATIC_REQUEST == 'true' }}
4866
env:
4967
AUTO_BETA_ENABLED: ${{ vars.DESKTOP_AUTO_BETA_ENABLED }}
50-
RELEASE_TAG: ${{ inputs.release_tag }}
5168
run: |
5269
set -euo pipefail
5370
if [[ "${AUTO_BETA_ENABLED,,}" == "false" ]]; then
@@ -68,18 +85,16 @@ jobs:
6885
- name: Download immutable qualification evidence
6986
uses: actions/download-artifact@v7
7087
with:
71-
name: desktop-qualification-evidence-${{ inputs.release_tag }}
72-
run-id: ${{ inputs.qualification_run_id }}
88+
name: desktop-qualification-evidence-${{ env.RELEASE_TAG }}
89+
run-id: ${{ env.QUALIFICATION_RUN_ID }}
7390
github-token: ${{ github.token }}
7491
path: /tmp/desktop-qualification-evidence
7592

7693
- name: Download and validate qualified candidate
7794
id: candidate
7895
env:
7996
GH_TOKEN: ${{ steps.app-token.outputs.token }}
80-
RELEASE_TAG: ${{ inputs.release_tag }}
8197
REPO: ${{ github.repository }}
82-
QUALIFICATION_RUN_ID: ${{ inputs.qualification_run_id }}
8398
run: |
8499
set -euo pipefail
85100
if ! printf '%s\n' "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+(\.[0-9]+)?\+[0-9]+-macos$'; then
@@ -96,9 +111,13 @@ jobs:
96111
test "$(jq -r .head_repository.full_name <<<"$run")" = "$REPO"
97112
test "$(jq -r .event <<<"$run")" = workflow_dispatch
98113
test "$(jq -r .path <<<"$run")" = .github/workflows/desktop_qualify_beta.yml
99-
test "$(jq -r .head_branch <<<"$run")" = main
114+
test "$(jq -r .head_branch <<<"$run")" = "$RELEASE_TAG"
100115
test "$(jq -r .name <<<"$run")" = 'Qualify Desktop Beta Candidate'
101116
test "$(jq -r .head_sha <<<"$run")" = "$TARGET_SHA"
117+
if [[ "$GITHUB_EVENT_NAME" == workflow_run ]]; then
118+
test "$QUALIFICATION_RUN_ID" = "$QUALIFIER_EVENT_RUN_ID"
119+
test "$QUALIFIER_EVENT_HEAD_SHA" = "$TARGET_SHA"
120+
fi
102121
gh release view "$RELEASE_TAG" --repo "$REPO" \
103122
--json tagName,body,isDraft,isPrerelease,publishedAt,assets \
104123
> /tmp/desktop-beta-release.json
@@ -154,7 +173,6 @@ jobs:
154173
- name: Advance explicit beta pointer
155174
env:
156175
PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
157-
RELEASE_TAG: ${{ inputs.release_tag }}
158176
run: |
159177
set -euo pipefail
160178
ADMIN_KEY=$(gcloud secrets versions access latest --secret=ADMIN_KEY --project "$PROJECT_ID")
@@ -175,7 +193,6 @@ jobs:
175193
- name: Mark GitHub release live beta
176194
env:
177195
GH_TOKEN: ${{ steps.app-token.outputs.token }}
178-
RELEASE_TAG: ${{ inputs.release_tag }}
179196
REPO: ${{ github.repository }}
180197
run: |
181198
set -euo pipefail
@@ -264,4 +281,4 @@ jobs:
264281
ADMIN_KEY=$(gcloud secrets versions access latest --secret=ADMIN_KEY --project "$PROJECT_ID")
265282
curl -fsS -X POST -H "secret-key: $ADMIN_KEY" \
266283
https://api.omi.me/v2/desktop/clear-cache >/dev/null
267-
echo "Promoted ${{ inputs.release_tag }} to qualified beta." >> "$GITHUB_STEP_SUMMARY"
284+
echo "Promoted $RELEASE_TAG to qualified beta." >> "$GITHUB_STEP_SUMMARY"

.github/workflows/desktop_promote_prod.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ jobs:
5252
environment: prod
5353
runs-on: ubuntu-latest
5454
steps:
55+
- name: Require explicit Stable confirmation
56+
env:
57+
CONFIRM: ${{ inputs.confirm }}
58+
OPERATION: ${{ inputs.operation }}
59+
run: |
60+
set -euo pipefail
61+
python3 .github/scripts/require_stable_promotion_confirmation.py \
62+
--operation "$OPERATION" --confirm "$CONFIRM"
63+
5564
- name: Checkout promotion controls
5665
uses: actions/checkout@v7
5766
with:
@@ -72,11 +81,9 @@ jobs:
7281
GH_TOKEN: ${{ steps.app-token.outputs.token }}
7382
REPO: ${{ github.repository }}
7483
RELEASE_TAG: ${{ inputs.release_tag }}
75-
CONFIRM: ${{ inputs.confirm }}
7684
QUALIFICATION_RUN_ID: ${{ inputs.qualification_run_id }}
7785
run: |
7886
set -euo pipefail
79-
test "$CONFIRM" = promote-stable || { echo 'confirm must be exactly: promote-stable' >&2; exit 1; }
8087
printf '%s\n' "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+(\.[0-9]+)?\+[0-9]+-macos$' || {
8188
echo "Invalid macOS release tag: $RELEASE_TAG" >&2; exit 1;
8289
}

0 commit comments

Comments
 (0)