Skip to content

Commit baf812c

Browse files
committed
Add workflow and script to automatically update distribution snapshots
Add a GitHub Actions workflow that runs every three days to pin each distribution to its latest snapshot. The workflow runs tools/update-snapshot.py for each distribution (arch, centos, debian, fedora, opensuse, ubuntu) which fetches the latest snapshot via "mkosi latest-snapshot", updates the corresponding mkosi config file with the new Snapshot= setting, and commits the result. The workflow then creates a pull request for the update using github-script. Each distribution gets its own dedicated branch (update-snapshot/<distro>) so that only a single pull request is open per distribution at a time. If an existing pull request is already open, it is updated in place by force-pushing to the branch. Auto-merge is enabled on each pull request via the GraphQL enablePullRequestAutoMerge mutation so that the snapshot update lands automatically once CI is green.
1 parent e9abfab commit baf812c

2 files changed

Lines changed: 252 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# SPDX-License-Identifier: LGPL-2.1-or-later
2+
name: update-snapshot
3+
4+
permissions:
5+
contents: write
6+
pull-requests: write
7+
8+
on:
9+
schedule:
10+
# Update snapshots every three days at midnight
11+
- cron: "0 0 */3 * *"
12+
workflow_dispatch:
13+
14+
jobs:
15+
update-snapshot:
16+
runs-on: ubuntu-24.04
17+
if: github.repository == 'daandemeyer/mkosi'
18+
strategy:
19+
fail-fast: false
20+
matrix:
21+
distro:
22+
- arch
23+
- centos
24+
- debian
25+
- fedora
26+
- opensuse
27+
- ubuntu
28+
29+
steps:
30+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
31+
32+
- uses: systemd/mkosi@main
33+
34+
- name: Configure git
35+
run: |
36+
git config user.name "github-actions[bot]"
37+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
38+
39+
- name: Update snapshot
40+
id: update
41+
run: |
42+
branch="update-snapshot/${{ matrix.distro }}"
43+
git checkout -B "$branch"
44+
45+
tools/update-snapshot.py -d ${{ matrix.distro }} -c
46+
47+
if git diff --quiet HEAD~1 HEAD 2>/dev/null; then
48+
echo "updated=false" >>"$GITHUB_OUTPUT"
49+
else
50+
git push -f origin "$branch"
51+
echo "updated=true" >>"$GITHUB_OUTPUT"
52+
echo "branch=$branch" >>"$GITHUB_OUTPUT"
53+
echo "title=$(git log -1 --format=%s)" >>"$GITHUB_OUTPUT"
54+
fi
55+
56+
- name: Create or update pull request
57+
if: steps.update.outputs.updated == 'true'
58+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea
59+
with:
60+
script: |
61+
const branch = '${{ steps.update.outputs.branch }}';
62+
const title = '${{ steps.update.outputs.title }}';
63+
const distro = '${{ matrix.distro }}';
64+
const body = `Automated snapshot update for ${distro}.`;
65+
66+
const { data: pulls } = await github.rest.pulls.list({
67+
owner: context.repo.owner,
68+
repo: context.repo.repo,
69+
state: 'open',
70+
head: `${context.repo.owner}:${branch}`,
71+
});
72+
73+
let number;
74+
75+
if (pulls.length > 0) {
76+
const pull = pulls[0];
77+
await github.rest.pulls.update({
78+
owner: context.repo.owner,
79+
repo: context.repo.repo,
80+
pull_number: pull.number,
81+
title: title,
82+
body: body,
83+
});
84+
console.log(`Updated existing pull request #${pull.number}`);
85+
number = pull.number;
86+
} else {
87+
const { data: pr } = await github.rest.pulls.create({
88+
owner: context.repo.owner,
89+
repo: context.repo.repo,
90+
title: title,
91+
body: body,
92+
head: branch,
93+
base: 'main',
94+
});
95+
console.log(`Created pull request #${pr.number}`);
96+
number = pr.number;
97+
}
98+
99+
// Enable auto-merge so the PR merges automatically once CI is green.
100+
const { data: pullData } = await github.rest.pulls.get({
101+
owner: context.repo.owner,
102+
repo: context.repo.repo,
103+
pull_number: number,
104+
});
105+
106+
await github.graphql(`
107+
mutation($pullRequestId: ID!) {
108+
enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: MERGE }) {
109+
clientMutationId
110+
}
111+
}
112+
`, { pullRequestId: pullData.node_id });
113+
114+
console.log(`Enabled auto-merge for pull request #${number}`);

tools/update-snapshot.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: LGPL-2.1-or-later
3+
4+
"""Fetch the latest snapshot for a distribution and update the mkosi config."""
5+
6+
import argparse
7+
import configparser
8+
import shlex
9+
import subprocess
10+
import sys
11+
12+
from pathlib import Path
13+
14+
15+
def parse_args() -> argparse.Namespace:
16+
p = argparse.ArgumentParser(description=__doc__)
17+
p.add_argument("--distribution", "-d", required=True)
18+
p.add_argument("--commit", "-c", action="store_true", default=False)
19+
20+
return p.parse_args()
21+
22+
23+
def find_config(distribution: str) -> Path:
24+
for p in (
25+
Path(f"mkosi.conf.d/{distribution}.conf"),
26+
Path(f"mkosi.conf.d/{distribution}/mkosi.conf"),
27+
):
28+
if p.exists():
29+
return p
30+
31+
print(f"No config file found for {distribution}", file=sys.stderr)
32+
sys.exit(1)
33+
34+
35+
def read_release(path: Path) -> str | None:
36+
config = configparser.ConfigParser()
37+
# Preserve case of keys.
38+
config.optionxform = str # type: ignore[assignment]
39+
config.read(path)
40+
41+
return config.get("Distribution", "Release", fallback=None)
42+
43+
44+
def update_snapshot(path: Path, snapshot: str) -> bool:
45+
lines = path.read_text().splitlines()
46+
47+
# Check if there's already a Snapshot= line we can replace.
48+
found = False
49+
new = []
50+
for line in lines:
51+
if line.startswith("Snapshot="):
52+
if line == f"Snapshot={snapshot}":
53+
# Already up to date.
54+
return False
55+
new.append(f"Snapshot={snapshot}")
56+
found = True
57+
else:
58+
new.append(line)
59+
60+
if not found:
61+
# Add Snapshot= after the last key in the [Distribution] section.
62+
result = []
63+
added = False
64+
in_distribution = False
65+
for i, line in enumerate(new):
66+
result.append(line)
67+
if line.strip() == "[Distribution]":
68+
in_distribution = True
69+
continue
70+
if in_distribution:
71+
# Check if the next line starts a new section or if we're at the last line of the section.
72+
next_is_end = (i + 1 >= len(new)) or new[i + 1].startswith("[")
73+
is_blank = line.strip() == ""
74+
if next_is_end and not is_blank:
75+
result.append(f"Snapshot={snapshot}")
76+
added = True
77+
in_distribution = False
78+
elif next_is_end and is_blank:
79+
# Insert before the blank line.
80+
result.pop()
81+
result.append(f"Snapshot={snapshot}")
82+
result.append(line)
83+
added = True
84+
in_distribution = False
85+
86+
if not added:
87+
# No [Distribution] section exists; add one.
88+
result = new + ["", "[Distribution]", f"Snapshot={snapshot}"]
89+
90+
new = result
91+
92+
path.write_text("\n".join(new) + "\n")
93+
return True
94+
95+
96+
def commit(distribution: str, release: str | None, path: Path, snapshot: str) -> None:
97+
if release:
98+
msg = f"mkosi: Update {distribution} {release} snapshot to {snapshot}"
99+
else:
100+
msg = f"mkosi: Update {distribution} snapshot to {snapshot}"
101+
102+
add_cmd = ["git", "add", str(path)]
103+
print(f"+ {shlex.join(add_cmd)}")
104+
subprocess.run(add_cmd, check=True)
105+
106+
commit_cmd = ["git", "commit", "-m", msg]
107+
print(f"+ {shlex.join(commit_cmd)}")
108+
subprocess.run(commit_cmd, check=True)
109+
110+
111+
def main() -> None:
112+
args = parse_args()
113+
114+
path = find_config(args.distribution)
115+
release = read_release(path)
116+
117+
cmd = [
118+
"mkosi",
119+
"-d", args.distribution,
120+
"latest-snapshot",
121+
]
122+
print(f"+ {shlex.join(cmd)}")
123+
snapshot = subprocess.check_output(cmd, text=True).strip()
124+
125+
print(f"Latest snapshot for {args.distribution}: {snapshot}")
126+
127+
if not update_snapshot(path, snapshot):
128+
print("Snapshot already up to date, nothing to do.")
129+
return
130+
131+
print(f"Updated {path} with Snapshot={snapshot}")
132+
133+
if args.commit:
134+
commit(args.distribution, release, path, snapshot)
135+
136+
137+
if __name__ == "__main__":
138+
main()

0 commit comments

Comments
 (0)