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
4 changes: 4 additions & 0 deletions .github/workflows/ci-guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,10 @@ jobs:
if: ${{ matrix.group == 'release-notary' }}
run: bash ./tests/test_nightly_universal_build.sh

- name: Validate nightly release notes and appcast summaries
if: ${{ matrix.group == 'release-notary' }}
run: python3 tests/test_nightly_release_notes.py

- name: Validate nightly Sparkle key selection
if: ${{ matrix.group == 'release-notary' }}
run: bash ./tests/test_nightly_sparkle_key.sh
Expand Down
50 changes: 35 additions & 15 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2013,6 +2013,11 @@ jobs:
# Production secrets: GitHub releases them only to protected refs (the
# environment's deployment branch policy: main, tags v*).
environment: release
permissions:
contents: write
pull-requests: read
attestations: write
id-token: write
# rc/** branches share one release. Serialize its mutable aliases and feeds
# by channel, even when workflow-level groups have different branch names.
concurrency:
Expand Down Expand Up @@ -2078,6 +2083,34 @@ jobs:
cp appcast.xml appcast-universal.xml
ls -l

- name: Prepare nightly release notes and appcast summaries
if: needs.decide.outputs.should_publish == 'true'
env:
GH_TOKEN: ${{ github.token }}
NOTES_HEAD: ${{ needs.decide.outputs.head_sha }}
NOTES_BRANCH: ${{ github.ref_name }}
NOTES_APP_NAME: ${{ needs.decide.outputs.app_name }}
NOTES_BUNDLE_ID: ${{ needs.decide.outputs.bundle_id }}
run: |
set -euo pipefail
cat > nightly-downloads.md <<EOF
Automated ${CHANNEL} build for \`${NOTES_HEAD:0:7}\` from \`${NOTES_BRANCH}\`.
Published commit: \`${NOTES_HEAD}\`.
**${NOTES_APP_NAME}** ships one app per architecture (bundle ID \`${NOTES_BUNDLE_ID}\`):
- [Apple silicon: ${CHANNEL_DMG_PREFIX}-arm64.dmg](https://github.com/${GITHUB_REPOSITORY}/releases/download/${CHANNEL_RELEASE_TAG}/${CHANNEL_DMG_PREFIX}-arm64.dmg), feed \`appcast-arm64.xml\`
- [Intel: ${CHANNEL_DMG_PREFIX}-x86_64.dmg](https://github.com/${GITHUB_REPOSITORY}/releases/download/${CHANNEL_RELEASE_TAG}/${CHANNEL_DMG_PREFIX}-x86_64.dmg), feed \`appcast-x86_64.xml\`
- [Universal (any Mac): ${CHANNEL_DMG_PREFIX}.dmg](https://github.com/${GITHUB_REPOSITORY}/releases/download/${CHANNEL_RELEASE_TAG}/${CHANNEL_DMG_PREFIX}.dmg), feed \`appcast.xml\`; the app switches to its architecture's feed on the first update check
EOF
# Read the live completed publication under the channel writer lock,
# rather than the decide job's baseline from before the build started.
# nightly_release_notes.py preserves the cmux-published-sha: marker
# used for the next exact range and its repair/recovery path.
python3 scripts/ci/nightly_release_notes.py \
--repo "$GITHUB_REPOSITORY" --tag "$CHANNEL_RELEASE_TAG" \
--head "$NOTES_HEAD" --branch "$NOTES_BRANCH" \
--details nightly-downloads.md --out nightly-notes.md \
--appcasts nightly-out --build "$NIGHTLY_BUILD"

- name: Upload branch nightly artifacts
if: needs.decide.outputs.should_publish != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down Expand Up @@ -2141,13 +2174,7 @@ jobs:
prerelease: true
make_latest: false
draft: false
body: |
Automated ${{ needs.decide.outputs.channel }} build for `${{ needs.decide.outputs.short_sha }}` from `${{ github.ref_name }}`.
Published commit: `${{ needs.decide.outputs.head_sha }}`.
**${{ needs.decide.outputs.app_name }}** ships one app per architecture (bundle ID `${{ needs.decide.outputs.bundle_id }}`):
- [Apple silicon: ${{ needs.decide.outputs.dmg_prefix }}-arm64.dmg](https://github.com/manaflow-ai/cmux/releases/download/${{ needs.decide.outputs.release_tag }}/${{ needs.decide.outputs.dmg_prefix }}-arm64.dmg), feed `appcast-arm64.xml`
- [Intel: ${{ needs.decide.outputs.dmg_prefix }}-x86_64.dmg](https://github.com/manaflow-ai/cmux/releases/download/${{ needs.decide.outputs.release_tag }}/${{ needs.decide.outputs.dmg_prefix }}-x86_64.dmg), feed `appcast-x86_64.xml`
- [Universal (any Mac): ${{ needs.decide.outputs.dmg_prefix }}.dmg](https://github.com/manaflow-ai/cmux/releases/download/${{ needs.decide.outputs.release_tag }}/${{ needs.decide.outputs.dmg_prefix }}.dmg), feed `appcast.xml`; the app switches to its architecture's feed on the first update check
body_path: nightly-notes.md

- name: Upload nightly appcasts to R2
if: needs.decide.outputs.should_publish == 'true'
Expand Down Expand Up @@ -2187,14 +2214,7 @@ jobs:
prerelease: true
make_latest: false
draft: false
body: |
<!-- cmux-published-sha: ${{ needs.decide.outputs.head_sha }} -->
Automated ${{ needs.decide.outputs.channel }} build for `${{ needs.decide.outputs.short_sha }}` from `${{ github.ref_name }}`.
Published commit: `${{ needs.decide.outputs.head_sha }}`.
**${{ needs.decide.outputs.app_name }}** ships one app per architecture (bundle ID `${{ needs.decide.outputs.bundle_id }}`):
- [Apple silicon: ${{ needs.decide.outputs.dmg_prefix }}-arm64.dmg](https://github.com/manaflow-ai/cmux/releases/download/${{ needs.decide.outputs.release_tag }}/${{ needs.decide.outputs.dmg_prefix }}-arm64.dmg), feed `appcast-arm64.xml`
- [Intel: ${{ needs.decide.outputs.dmg_prefix }}-x86_64.dmg](https://github.com/manaflow-ai/cmux/releases/download/${{ needs.decide.outputs.release_tag }}/${{ needs.decide.outputs.dmg_prefix }}-x86_64.dmg), feed `appcast-x86_64.xml`
- [Universal (any Mac): ${{ needs.decide.outputs.dmg_prefix }}.dmg](https://github.com/manaflow-ai/cmux/releases/download/${{ needs.decide.outputs.release_tag }}/${{ needs.decide.outputs.dmg_prefix }}.dmg), feed `appcast.xml`; the app switches to its architecture's feed on the first update check
body_path: nightly-notes.published.md

- name: Mint release tag app token
id: release-tag-token
Expand Down
212 changes: 212 additions & 0 deletions scripts/ci/nightly_release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Summarize merged PRs between verified nightly publications, without changing assets."""
from __future__ import annotations

import argparse
import json
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import quote
from xml.dom import minidom

MARKER = re.compile(r"<!--\s*cmux-published-sha:\s*([0-9a-f]{40})\s*-->", re.I)
SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle"
REPAIR = "https://github.com/manaflow-ai/cmuxterm-hq/blob/main/REPAIR.md"


class GitHub:
def __init__(self, repo: str):
self.repo = repo

@staticmethod
def request(args: list[str]) -> dict:
result = subprocess.run(["gh", "api", *args], capture_output=True, text=True, timeout=90)
if result.returncode:
raise RuntimeError("GitHub metadata request failed; check Actions token contents/pull-requests read access and API availability")
value = json.loads(result.stdout)
if isinstance(value, dict) and value.get("errors"):
raise RuntimeError("GitHub GraphQL metadata query returned errors; retry after checking API access")
return value

def rest(self, endpoint: str) -> dict:
return self.request([f"repos/{self.repo}/{endpoint}"])

def graphql(self, query: str) -> dict:
return self.request(["graphql", "-f", f"query={query}"])["data"]["repository"]


def published_sha(body: str) -> str | None:
match = MARKER.search(body)
return match.group(1).lower() if match else None


def collect_prs(github: GitHub, base: str, head: str, branch: str) -> list[dict]:
"""Use commit membership, not merge dates, so late/backported PRs cannot leak in."""
if base == head:
return []
commits = []
for page in range(1, 101):
comparison = github.rest(f"compare/{base}...{head}?per_page=100&page={page}")
if comparison["status"] not in ("ahead", "identical"):
raise RuntimeError("Published SHA is not an ancestor of the built SHA; verify the channel marker before retrying")
commits.extend(c["sha"] for c in comparison["commits"])
if len(commits) >= comparison["total_commits"]:
break
if not comparison["commits"]:
raise RuntimeError("GitHub compare pagination ended early; retry metadata generation")
else:
raise RuntimeError("More than 10000 commits since publication; repair the channel marker before retrying")
owner, name = github.repo.split("/")
members = set(commits)
prs = {}
for offset in range(0, len(commits), 25):
objects = []
for index, sha in enumerate(commits[offset:offset + 25]):
objects.append(f'''c{index}: object(oid: "{sha}") {{ ... on Commit {{ associatedPullRequests(first: 10) {{
pageInfo {{ hasNextPage }} nodes {{ number title url mergedAt baseRefName mergeCommit {{ oid }}
labels(first: 100) {{ nodes {{ name }} }}
files(first: 100) {{ totalCount nodes {{ path }} }}
}}
}} }} }}''')
result = github.graphql('query { repository(owner: ' + json.dumps(owner) + ', name: ' + json.dumps(name) + ') { ' + ' '.join(objects) + ' } }')
for obj in result.values():
if obj is None:
raise RuntimeError("GitHub could not resolve a compared commit; retry metadata generation")
associated = obj["associatedPullRequests"]
if associated["pageInfo"]["hasNextPage"]:
raise RuntimeError("A commit has more than ten associated PRs; inspect its associations before retrying")
for pr in associated["nodes"]:
merged = pr.get("mergeCommit") or {}
if pr["mergedAt"] and pr["baseRefName"] == branch and merged.get("oid") in members:
prs[pr["number"]] = pr
return list(prs.values())


def infrastructure(pr: dict) -> bool:
title = pr["title"].strip().lower()
if re.match(r"^(ci|docs?|tests?)(\([^)]*\))?\s*[:!]", title):
return True
files = pr.get("files", {})
paths = [f["path"] for f in files.get("nodes", [])]
if not paths or len(paths) != files.get("totalCount"):
return False
return all(p.startswith((".github/", "docs/", "tests/", "cmuxTests/", "cmuxUITests/", "scripts/ci/"))
or p.endswith((".md", ".mdx")) or "/Tests/" in p for p in paths)


def category(pr: dict) -> str:
labels = {x["name"].lower().replace("-", " ") for x in pr.get("labels", {}).get("nodes", [])}
title = pr["title"].lower()
if labels & {"default call", "needs a call"} or re.search(r"\bdefaults?\b", title):
return "Changed defaults"
if re.match(r"^(feat|new)(\([^)]*\))?\s*[:!]", title) or re.match(r"^(add|introduce|support|enable|show)\b", title):
return "New"
return "Fixes"


def clean_title(title: str) -> str:
return " ".join(title.split())


def markdown_title(title: str) -> str:
return re.sub(r"([\\`*_\[\]<>])", r"\\\1", clean_title(title))


def summarize(prs: list[dict], repo: str, base: str | None, head: str, limit: int = 40) -> tuple[str, str]:
ordered = sorted({p["number"]: p for p in prs}.values(), key=lambda p: (p["mergedAt"], p["number"]), reverse=True)
infra = sum(infrastructure(p) for p in ordered)
product = [p for p in ordered if not infrastructure(p)]
visible = product[:limit]
lines = ["## What changed", ""]
if base is None:
lines += ["The previous published commit was not recorded, so a change range is not available for this build.", ""]
elif not product:
lines += ["No product PRs merged since the previous published build.", ""]
for group in ("Fixes", "New", "Changed defaults"):
entries = [p for p in visible if category(p) == group]
if entries:
lines += [f"### {group}", ""]
lines += [f'- {markdown_title(p["title"])} ([#{p["number"]}](https://github.com/{repo}/pull/{p["number"]}))' for p in entries]
lines += [""]
if len(product) > limit:
lines += [f"[and {len(product) - limit} more](https://github.com/{repo}/compare/{base}...{head})", ""]
if infra:
lines += [f"Infrastructure: {infra}", ""]
if base and base != head:
lines += [f"[Compare published builds](https://github.com/{repo}/compare/{base}...{head})", ""]
plain_lines = [f'{clean_title(p["title"])} (#{p["number"]})' for p in product[:5]]
if len(product) > 5:
plain_lines.append(f"and {len(product) - 5} more")
if infra:
plain_lines.append(f"Infrastructure: {infra}")
plain = "\n".join(plain_lines)
if not plain:
plain = "No product PRs merged since the previous published build." if base else "See the release page for build details."
return "\n".join(lines), plain


def update_appcasts(directory: Path, build: str, summary: str) -> None:
expected = {"appcast-arm64.xml", "appcast-x86_64.xml", "appcast-universal.xml", "appcast.xml"}
feeds = sorted(directory.glob("appcast*.xml"))
if {feed.name for feed in feeds} != expected:
raise RuntimeError("Expected all four nightly appcasts before generating notes; download the complete signed variant artifacts")
# Build all replacements before writing, so a missing current item fails cleanly.
replacements = []
for feed in feeds:
doc = minidom.parse(str(feed))
matches = []
for item in doc.getElementsByTagName("item"):
versions = item.getElementsByTagNameNS(SPARKLE, "version")
enclosures = item.getElementsByTagName("enclosure")
if any(v.firstChild and v.firstChild.nodeValue == build for v in versions) or any(e.getAttributeNS(SPARKLE, "version") == build for e in enclosures):
matches.append(item)
if len(matches) != 1:
raise RuntimeError(f"{feed.name} must contain exactly one item for build {build}; check downloaded artifacts")
item = matches[0]
for old in list(item.childNodes):
if old.nodeType == old.ELEMENT_NODE and old.tagName == "description":
item.removeChild(old)
description = doc.createElement("description")
description.appendChild(doc.createTextNode(summary))
item.appendChild(description)
replacements.append((feed, doc.toxml(encoding="utf-8")))
for feed, xml in replacements:
feed.write_bytes(xml)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("--head", required=True)
parser.add_argument("--branch", required=True)
parser.add_argument("--details", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--appcasts", type=Path, required=True)
parser.add_argument("--build", required=True)
args = parser.parse_args()
if not re.fullmatch(r"[\w.-]+/[\w.-]+", args.repo) or not re.fullmatch(r"[0-9a-f]{40}", args.head):
raise ValueError("Expected owner/repo and a full built SHA")
github = GitHub(args.repo)
release = github.rest(f"releases/tags/{quote(args.tag, safe='')}")
base = published_sha(release.get("body") or "")
if base is None:
print(f"::warning::No previous publication marker; restore cmux-published-sha in the release body to recover the change range. Repair: {REPAIR}")
prs = collect_prs(github, base, args.head, args.branch) if base else []
markdown, plain = summarize(prs, args.repo, base, args.head)
body = markdown + "\n## Downloads\n\n" + args.details.read_text()
args.out.write_text(body)
args.out.with_suffix(".published.md").write_text(f"<!-- cmux-published-sha: {args.head} -->\n" + body)
update_appcasts(args.appcasts, args.build, plain)
print(f"Prepared nightly notes for {len(prs)} merged PRs; baseline {base or 'unavailable'}")
return 0


if __name__ == "__main__":
try:
sys.exit(main())
except (OSError, RuntimeError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
print(f"::error::{error}. Fix metadata access, publication marker, or downloaded appcasts and rerun the publish job. Repair: {REPAIR}", file=sys.stderr)
sys.exit(1)
4 changes: 4 additions & 0 deletions tests/test-execution.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1595,3 +1595,7 @@ lane = "linux-guard"
[[test]]
path = "tests/test_gh_merge_green.py"
lane = "linux-guard"

[[test]]
path = "tests/test_nightly_release_notes.py"
lane = "linux-guard"
Loading
Loading