Merge pull request #1324 from nicolasleao/main
chore: add updated a0-development skill
frdel committed
Mar 26, 2026 at 08:50 UTC
5ba7cfc99fe642f6ff1a5f4750ce84c8a73f06a7
3 files changed
+924
.github/scripts/docker_release_plan.py
new
+580
@@ -0,0 +1,580 @@
1
+#!/usr/bin/env python3
2
+from __future__ import annotations
3
+
4
+import json
5
+import os
6
+import re
7
+import subprocess
8
+import sys
9
+from dataclasses import asdict, dataclass
10
+
11
+
12
+def fail(message: str) -> None:
13
+ print(message, file=sys.stderr)
14
+ raise SystemExit(1)
15
+
16
+
17
+def write_output(name: str, value: str) -> None:
18
+ output_path = os.environ.get("GITHUB_OUTPUT")
19
+ if not output_path:
20
+ return
21
+ with open(output_path, "a", encoding="utf-8") as handle:
22
+ handle.write(f"{name}<<__EOF__\n{value}\n__EOF__\n")
23
+
24
+
25
+def write_summary(lines: list[str]) -> None:
26
+ summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
27
+ if not summary_path or not lines:
28
+ return
29
+ with open(summary_path, "a", encoding="utf-8") as handle:
30
+ handle.write("## Docker publish plan\n\n")
31
+ for line in lines:
32
+ handle.write(f"- {line}\n")
33
+
34
+
35
+def run_command(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
36
+ result = subprocess.run(args, capture_output=True, text=True)
37
+ if check and result.returncode != 0:
38
+ command = " ".join(args)
39
+ fail(f"Command failed ({command}):\n{result.stderr.strip()}")
40
+ return result
41
+
42
+
43
+def git(*args: str, check: bool = True) -> str:
44
+ return run_command("git", *args, check=check).stdout.strip()
45
+
46
+
47
+def docker_tag_exists(image_repo: str, tag: str) -> bool:
48
+ result = run_command(
49
+ "docker",
50
+ "buildx",
51
+ "imagetools",
52
+ "inspect",
53
+ f"{image_repo}:{tag}",
54
+ check=False,
55
+ )
56
+ return result.returncode == 0
57
+
58
+
59
+def split_branches(raw: str) -> list[str]:
60
+ parts = re.split(r"[\s,]+", raw.strip())
61
+ return [part for part in parts if part]
62
+
63
+
64
+@dataclass(frozen=True)
65
+class Config:
66
+ allowed_branches: list[str]
67
+ main_branch: str
68
+ image_repo: str
69
+ tag_pattern: re.Pattern[str]
70
+ min_version: tuple[int, int]
71
+ event_name: str
72
+ source_ref_name: str
73
+ source_ref_type: str
74
+ manual_tag: str
75
+ before_sha: str
76
+ after_sha: str
77
+
78
+
79
+@dataclass(frozen=True)
80
+class BranchState:
81
+ branch: str
82
+ valid_tags: list[str]
83
+ latest_tag: str | None
84
+
85
+
86
+@dataclass
87
+class Candidate:
88
+ branch: str
89
+ source_tag: str
90
+ mode: str
91
+ publish_version: bool
92
+ publish_branch_tag: bool
93
+ reason: str
94
+
95
+
96
+def load_config() -> Config:
97
+ allowed_branches = split_branches(os.environ["ALLOWED_BRANCHES"])
98
+ if not allowed_branches:
99
+ fail("ALLOWED_BRANCHES must not be empty.")
100
+ main_branch = os.environ["MAIN_BRANCH"].strip()
101
+ if main_branch not in allowed_branches:
102
+ fail("MAIN_BRANCH must also be listed in ALLOWED_BRANCHES.")
103
+
104
+ tag_regex = os.environ["RELEASE_TAG_REGEX"]
105
+ return Config(
106
+ allowed_branches=allowed_branches,
107
+ main_branch=main_branch,
108
+ image_repo=os.environ["DOCKER_IMAGE_REPO"].strip(),
109
+ tag_pattern=re.compile(tag_regex),
110
+ min_version=(
111
+ int(os.environ["MIN_RELEASE_MAJOR"]),
112
+ int(os.environ["MIN_RELEASE_MINOR"]),
113
+ ),
114
+ event_name=os.environ["EVENT_NAME"].strip(),
115
+ source_ref_name=os.environ.get("SOURCE_REF_NAME", "").strip(),
116
+ source_ref_type=os.environ.get("SOURCE_REF_TYPE", "").strip(),
117
+ manual_tag=os.environ.get("MANUAL_TAG", "").strip(),
118
+ before_sha=os.environ.get("BEFORE_SHA", "").strip(),
119
+ after_sha=os.environ.get("AFTER_SHA", "").strip(),
120
+ )
121
+
122
+
123
+def parse_release_tag(config: Config, tag: str) -> tuple[int, int] | None:
124
+ match = config.tag_pattern.fullmatch(tag)
125
+ if not match:
126
+ return None
127
+ version = (int(match.group(1)), int(match.group(2)))
128
+ if version < config.min_version:
129
+ return None
130
+ return version
131
+
132
+
133
+def tag_exists(tag: str) -> bool:
134
+ return run_command("git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}", check=False).returncode == 0
135
+
136
+
137
+def tag_commit(tag: str) -> str:
138
+ return git("rev-list", "-n", "1", f"refs/tags/{tag}")
139
+
140
+
141
+def branch_contains_commit(branch: str, commit: str) -> bool:
142
+ return (
143
+ run_command(
144
+ "git",
145
+ "merge-base",
146
+ "--is-ancestor",
147
+ commit,
148
+ f"origin/{branch}",
149
+ check=False,
150
+ ).returncode
151
+ == 0
152
+ )
153
+
154
+
155
+def ref_exists(ref: str) -> bool:
156
+ if not ref or re.fullmatch(r"0{40}", ref):
157
+ return False
158
+ return run_command("git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}", check=False).returncode == 0
159
+
160
+
161
+def releasable_tags_for_ref(config: Config, ref: str) -> list[str]:
162
+ if not ref_exists(ref):
163
+ return []
164
+
165
+ tagged_versions: list[tuple[tuple[int, int], str]] = []
166
+ merged_tags = git("tag", "--merged", ref)
167
+ for tag in merged_tags.splitlines():
168
+ version = parse_release_tag(config, tag.strip())
169
+ if version is None:
170
+ continue
171
+ tagged_versions.append((version, tag.strip()))
172
+
173
+ tagged_versions.sort(key=lambda item: item[0])
174
+ return [tag for _, tag in tagged_versions]
175
+
176
+
177
+def latest_releasable_tag_for_ref(config: Config, ref: str) -> str | None:
178
+ valid_tags = releasable_tags_for_ref(config, ref)
179
+ return valid_tags[-1] if valid_tags else None
180
+
181
+
182
+def collect_branch_states(config: Config, branches: list[str] | None = None) -> dict[str, BranchState]:
183
+ states: dict[str, BranchState] = {}
184
+ for branch in branches or config.allowed_branches:
185
+ if run_command("git", "show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}", check=False).returncode != 0:
186
+ fail(f"Allowed branch origin/{branch} was not fetched.")
187
+
188
+ valid_tags = releasable_tags_for_ref(config, f"origin/{branch}")
189
+ states[branch] = BranchState(
190
+ branch=branch,
191
+ valid_tags=valid_tags,
192
+ latest_tag=valid_tags[-1] if valid_tags else None,
193
+ )
194
+ return states
195
+
196
+
197
+def add_or_merge_candidate(candidates: dict[tuple[str, str, str], Candidate], candidate: Candidate) -> None:
198
+ key = (candidate.branch, candidate.source_tag, candidate.mode)
199
+ existing = candidates.get(key)
200
+ if existing is None:
201
+ candidates[key] = candidate
202
+ return
203
+ existing.publish_version = existing.publish_version or candidate.publish_version
204
+ existing.publish_branch_tag = existing.publish_branch_tag or candidate.publish_branch_tag
205
+ if candidate.reason not in existing.reason:
206
+ existing.reason = f"{existing.reason}; {candidate.reason}"
207
+
208
+
209
+def plan_tag_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
210
+ source_tag = config.source_ref_name
211
+ notes: list[str] = []
212
+ version = parse_release_tag(config, source_tag)
213
+ if version is None:
214
+ return [], [f"Skipped `{source_tag}` because it does not match `v{{X}}.{{Y}}` or is below v{config.min_version[0]}.{config.min_version[1]}."]
215
+ if not tag_exists(source_tag):
216
+ return [], [f"Skipped `{source_tag}` because the tag is not present after checkout."]
217
+
218
+ commit = tag_commit(source_tag)
219
+ candidates: list[Candidate] = []
220
+ found_branch = False
221
+ for branch, state in branch_states.items():
222
+ if not branch_contains_commit(branch, commit):
223
+ continue
224
+ found_branch = True
225
+ if state.latest_tag != source_tag:
226
+ notes.append(
227
+ f"Skipped `{source_tag}` on `{branch}` because `{state.latest_tag}` is the highest release tag currently reachable from that branch."
228
+ )
229
+ continue
230
+ candidates.append(
231
+ Candidate(
232
+ branch=branch,
233
+ source_tag=source_tag,
234
+ mode="push_latest_only",
235
+ publish_version=branch == config.main_branch,
236
+ publish_branch_tag=True,
237
+ reason=f"Automatic build for the latest release tag on `{branch}`.",
238
+ )
239
+ )
240
+
241
+ if not found_branch:
242
+ notes.append(f"Skipped `{source_tag}` because it is not reachable from any allowed branch.")
243
+ return candidates, notes
244
+
245
+
246
+def plan_branch_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
247
+ branch = config.source_ref_name
248
+ if branch not in branch_states:
249
+ return [], [f"Skipped `{branch}` because it is not an allowed release branch."]
250
+
251
+ before_tag = latest_releasable_tag_for_ref(config, config.before_sha)
252
+ after_tag = branch_states[branch].latest_tag
253
+ if after_tag is None:
254
+ return [], [f"Skipped `{branch}` because it has no releasable tags."]
255
+ if before_tag == after_tag:
256
+ return [], [f"Skipped `{branch}` because its highest release tag is still `{after_tag}`."]
257
+
258
+ return [
259
+ Candidate(
260
+ branch=branch,
261
+ source_tag=after_tag,
262
+ mode="push_promoted_tag",
263
+ publish_version=branch == config.main_branch,
264
+ publish_branch_tag=True,
265
+ reason=f"Automatic build for `{after_tag}` after it reached `{branch}`.",
266
+ )
267
+ ], []
268
+
269
+
270
+def plan_manual_exact(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
271
+ manual_tag = config.manual_tag
272
+ if parse_release_tag(config, manual_tag) is None:
273
+ fail(
274
+ f"Manual tag `{manual_tag}` is invalid. Expected `v{{X}}.{{Y}}` with a minimum of v{config.min_version[0]}.{config.min_version[1]}."
275
+ )
276
+ if not tag_exists(manual_tag):
277
+ fail(f"Manual tag `{manual_tag}` does not exist in the repository.")
278
+
279
+ commit = tag_commit(manual_tag)
280
+ notes: list[str] = []
281
+ candidates: list[Candidate] = []
282
+ for branch, state in branch_states.items():
283
+ if not branch_contains_commit(branch, commit):
284
+ continue
285
+ if branch == config.main_branch:
286
+ candidates.append(
287
+ Candidate(
288
+ branch=branch,
289
+ source_tag=manual_tag,
290
+ mode="manual_exact",
291
+ publish_version=True,
292
+ publish_branch_tag=state.latest_tag == manual_tag,
293
+ reason=f"Manual rebuild for `{manual_tag}` on `{branch}`.",
294
+ )
295
+ )
296
+ continue
297
+ if state.latest_tag != manual_tag:
298
+ notes.append(
299
+ f"Skipped `{manual_tag}` on `{branch}` because non-main branches only publish their current branch tag and `{state.latest_tag}` is newer."
300
+ )
301
+ continue
302
+ candidates.append(
303
+ Candidate(
304
+ branch=branch,
305
+ source_tag=manual_tag,
306
+ mode="manual_exact",
307
+ publish_version=False,
308
+ publish_branch_tag=True,
309
+ reason=f"Manual rebuild for the current branch image on `{branch}`.",
310
+ )
311
+ )
312
+
313
+ if not candidates:
314
+ notes.append(f"No eligible images were found for manual tag `{manual_tag}`.")
315
+ return candidates, notes
316
+
317
+
318
+def plan_manual_backfill(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
319
+ notes: list[str] = []
320
+ candidates: dict[tuple[str, str, str], Candidate] = {}
321
+
322
+ for branch, state in branch_states.items():
323
+ if not state.valid_tags:
324
+ notes.append(f"Branch `{branch}` has no releasable tags.")
325
+ continue
326
+
327
+ if branch == config.main_branch:
328
+ for tag in state.valid_tags:
329
+ if docker_tag_exists(config.image_repo, tag):
330
+ continue
331
+ add_or_merge_candidate(
332
+ candidates,
333
+ Candidate(
334
+ branch=branch,
335
+ source_tag=tag,
336
+ mode="manual_backfill",
337
+ publish_version=True,
338
+ publish_branch_tag=False,
339
+ reason=f"Missing Docker Hub tag `{tag}`.",
340
+ ),
341
+ )
342
+
343
+ latest_tag = state.latest_tag
344
+ if latest_tag and not docker_tag_exists(config.image_repo, "latest"):
345
+ add_or_merge_candidate(
346
+ candidates,
347
+ Candidate(
348
+ branch=branch,
349
+ source_tag=latest_tag,
350
+ mode="manual_backfill",
351
+ publish_version=False,
352
+ publish_branch_tag=True,
353
+ reason="Missing Docker Hub tag `latest`.",
354
+ ),
355
+ )
356
+ continue
357
+
358
+ if not docker_tag_exists(config.image_repo, branch):
359
+ add_or_merge_candidate(
360
+ candidates,
361
+ Candidate(
362
+ branch=branch,
363
+ source_tag=state.latest_tag,
364
+ mode="manual_backfill",
365
+ publish_version=False,
366
+ publish_branch_tag=True,
367
+ reason=f"Missing Docker Hub tag `{branch}`.",
368
+ ),
369
+ )
370
+
371
+ if not candidates:
372
+ notes.append("No missing Docker Hub tags were found.")
373
+ return list(candidates.values()), notes
374
+
375
+
376
+def plan_command() -> None:
377
+ config = load_config()
378
+ branch_states = collect_branch_states(config)
379
+
380
+ if config.event_name == "workflow_dispatch":
381
+ if config.manual_tag:
382
+ candidates, notes = plan_manual_exact(config, branch_states)
383
+ else:
384
+ candidates, notes = plan_manual_backfill(config, branch_states)
385
+ elif config.event_name == "push":
386
+ if config.source_ref_type == "tag":
387
+ candidates, notes = plan_tag_push(config, branch_states)
388
+ elif config.source_ref_type == "branch":
389
+ candidates, notes = plan_branch_push(config, branch_states)
390
+ else:
391
+ fail(f"Unsupported push ref type: {config.source_ref_type}")
392
+ else:
393
+ fail(f"Unsupported event: {config.event_name}")
394
+
395
+ summary_lines = [candidate.reason for candidate in candidates]
396
+ summary_lines.extend(notes)
397
+
398
+ matrix = {"include": [asdict(candidate) for candidate in candidates]}
399
+ write_output("has_work", "true" if candidates else "false")
400
+ write_output("matrix", json.dumps(matrix))
401
+ write_summary(summary_lines)
402
+
403
+ print(json.dumps(matrix, indent=2))
404
+ for line in summary_lines:
405
+ print(f"- {line}")
406
+
407
+
408
+def unique(items: list[str]) -> list[str]:
409
+ seen: set[str] = set()
410
+ output: list[str] = []
411
+ for item in items:
412
+ if item in seen:
413
+ continue
414
+ seen.add(item)
415
+ output.append(item)
416
+ return output
417
+
418
+
419
+def resolve_release_command() -> None:
420
+ config = load_config()
421
+ branch = os.environ["TARGET_BRANCH"].strip()
422
+ source_tag = os.environ["TARGET_TAG"].strip()
423
+ notes_dir = os.environ["RELEASE_NOTES_DIR"].strip()
424
+
425
+ if branch != config.main_branch:
426
+ write_output("should_release", "false")
427
+ write_output("skip_reason", f"Branch `{branch}` does not publish GitHub releases.")
428
+ return
429
+
430
+ branch_state = collect_branch_states(config, [branch])[branch]
431
+ if branch_state.latest_tag is None:
432
+ write_output("should_release", "false")
433
+ write_output("skip_reason", f"Branch `{branch}` has no releasable tags.")
434
+ return
435
+
436
+ if parse_release_tag(config, source_tag) is None or not tag_exists(source_tag):
437
+ write_output("should_release", "false")
438
+ write_output("skip_reason", f"Tag `{source_tag}` is not a releasable tag.")
439
+ return
440
+
441
+ commit = tag_commit(source_tag)
442
+ if not branch_contains_commit(branch, commit):
443
+ write_output("should_release", "false")
444
+ write_output("skip_reason", f"Tag `{source_tag}` is no longer reachable from `{branch}`.")
445
+ return
446
+
447
+ if branch_state.latest_tag != source_tag:
448
+ write_output("should_release", "false")
449
+ write_output(
450
+ "skip_reason",
451
+ f"Tag `{source_tag}` is not the highest release tag on `{branch}`.",
452
+ )
453
+ return
454
+
455
+ notes_path = os.path.join(notes_dir, f"{source_tag}.md")
456
+ if not os.path.exists(notes_path):
457
+ fail(
458
+ f"Expected release notes file `{notes_path}` for GitHub release `{source_tag}`."
459
+ )
460
+
461
+ with open(notes_path, "r", encoding="utf-8") as handle:
462
+ body = handle.read().strip()
463
+
464
+ write_output("should_release", "true")
465
+ write_output("release_tag", source_tag)
466
+ write_output("release_name", source_tag)
467
+ write_output("release_notes_path", notes_path)
468
+ write_output("release_body", body or "No release notes.")
469
+ print(source_tag)
470
+
471
+
472
+def resolve_build_command() -> None:
473
+ config = load_config()
474
+ branch = os.environ["TARGET_BRANCH"].strip()
475
+ source_tag = os.environ["TARGET_TAG"].strip()
476
+ mode = os.environ["TARGET_MODE"].strip()
477
+ publish_version = os.environ["TARGET_PUBLISH_VERSION"].strip().lower() == "true"
478
+ publish_branch_tag = os.environ["TARGET_PUBLISH_BRANCH_TAG"].strip().lower() == "true"
479
+
480
+ branch_state = collect_branch_states(config, [branch])[branch]
481
+ if branch_state.latest_tag is None:
482
+ write_output("should_build", "false")
483
+ write_output("skip_reason", f"Branch `{branch}` has no releasable tags.")
484
+ return
485
+
486
+ if parse_release_tag(config, source_tag) is None or not tag_exists(source_tag):
487
+ write_output("should_build", "false")
488
+ write_output("skip_reason", f"Tag `{source_tag}` is no longer available.")
489
+ return
490
+
491
+ commit = tag_commit(source_tag)
492
+ if not branch_contains_commit(branch, commit):
493
+ write_output("should_build", "false")
494
+ write_output("skip_reason", f"Tag `{source_tag}` is no longer reachable from `{branch}`.")
495
+ return
496
+
497
+ mutable_tag = "latest" if branch == config.main_branch else branch
498
+ tags_to_push: list[str] = []
499
+
500
+ if mode == "push_latest_only":
501
+ if branch_state.latest_tag != source_tag:
502
+ write_output("should_build", "false")
503
+ write_output(
504
+ "skip_reason",
505
+ f"Tag `{source_tag}` is no longer the highest release tag on `{branch}`.",
506
+ )
507
+ return
508
+ if publish_version:
509
+ tags_to_push.append(f"{config.image_repo}:{source_tag}")
510
+ if publish_branch_tag:
511
+ tags_to_push.append(f"{config.image_repo}:{mutable_tag}")
512
+
513
+ elif mode == "push_promoted_tag":
514
+ if branch_state.latest_tag != source_tag:
515
+ write_output("should_build", "false")
516
+ write_output(
517
+ "skip_reason",
518
+ f"Tag `{source_tag}` is no longer the highest release tag on `{branch}`.",
519
+ )
520
+ return
521
+ if publish_version and not docker_tag_exists(config.image_repo, source_tag):
522
+ tags_to_push.append(f"{config.image_repo}:{source_tag}")
523
+ if publish_branch_tag:
524
+ tags_to_push.append(f"{config.image_repo}:{mutable_tag}")
525
+
526
+ elif mode == "manual_exact":
527
+ if publish_version:
528
+ tags_to_push.append(f"{config.image_repo}:{source_tag}")
529
+ if publish_branch_tag and branch_state.latest_tag == source_tag:
530
+ tags_to_push.append(f"{config.image_repo}:{mutable_tag}")
531
+
532
+ elif mode == "manual_backfill":
533
+ if publish_version and not docker_tag_exists(config.image_repo, source_tag):
534
+ tags_to_push.append(f"{config.image_repo}:{source_tag}")
535
+ if publish_branch_tag:
536
+ if branch != config.main_branch and branch_state.latest_tag != source_tag:
537
+ write_output("should_build", "false")
538
+ write_output(
539
+ "skip_reason",
540
+ f"Tag `{source_tag}` is no longer the newest release tag on `{branch}`.",
541
+ )
542
+ return
543
+ if branch == config.main_branch and branch_state.latest_tag != source_tag:
544
+ publish_branch_tag = False
545
+ if publish_branch_tag and not docker_tag_exists(config.image_repo, mutable_tag):
546
+ tags_to_push.append(f"{config.image_repo}:{mutable_tag}")
547
+ else:
548
+ fail(f"Unsupported resolve-build mode: {mode}")
549
+
550
+ tags_to_push = unique(tags_to_push)
551
+ if not tags_to_push:
552
+ write_output("should_build", "false")
553
+ write_output("skip_reason", "All requested Docker tags already exist or are no longer eligible.")
554
+ return
555
+
556
+ write_output("should_build", "true")
557
+ write_output("tags", "\n".join(tags_to_push))
558
+ write_output("display_tags", ", ".join(tag.rsplit(":", 1)[1] for tag in tags_to_push))
559
+ print("\n".join(tags_to_push))
560
+
561
+
562
+def main() -> None:
563
+ if len(sys.argv) != 2:
564
+ fail("Usage: docker_release_plan.py <plan|resolve-build|resolve-release>")
565
+
566
+ command = sys.argv[1]
567
+ if command == "plan":
568
+ plan_command()
569
+ return
570
+ if command == "resolve-build":
571
+ resolve_build_command()
572
+ return
573
+ if command == "resolve-release":
574
+ resolve_release_command()
575
+ return
576
+ fail(f"Unknown command: {command}")
577
+
578
+
579
+if __name__ == "__main__":
580
+ main()
.github/workflows/close-inactive.yml
new
+108
@@ -0,0 +1,108 @@
1
+name: Close inactive issues and PRs
2
+
3
+on:
4
+ schedule:
5
+ - cron: "17 3 * * *"
6
+ workflow_dispatch:
7
+ inputs:
8
+ inactive_days:
9
+ description: "Close items with no activity for more than N days"
10
+ required: false
11
+ default: "90"
12
+ dry_run:
13
+ description: "If true, only print URLs (no comment/close)"
14
+ required: false
15
+ default: "true"
16
+
17
+permissions:
18
+ issues: write
19
+ pull-requests: write
20
+
21
+env:
22
+ DEFAULT_INACTIVE_DAYS: "90"
23
+ DEFAULT_DRY_RUN: "true"
24
+
25
+jobs:
26
+ close_inactive:
27
+ if: github.repository == 'agent0ai/agent-zero' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - name: Find and optionally close inactive issues/PRs
31
+ uses: actions/github-script@v7
32
+ env:
33
+ INACTIVE_DAYS: ${{ github.event_name == 'workflow_dispatch' && inputs.inactive_days || env.DEFAULT_INACTIVE_DAYS }}
34
+ DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || env.DEFAULT_DRY_RUN }}
35
+ with:
36
+ script: |
37
+ const inactiveDaysRaw = process.env.INACTIVE_DAYS ?? "90";
38
+ const inactiveDays = Number.parseInt(inactiveDaysRaw, 10);
39
+ if (!Number.isFinite(inactiveDays) || inactiveDays <= 0) {
40
+ core.setFailed(`Invalid INACTIVE_DAYS: ${inactiveDaysRaw}`);
41
+ return;
42
+ }
43
+
44
+ const dryRunRaw = (process.env.DRY_RUN ?? "true").toLowerCase();
45
+ const dryRun = ["1", "true", "yes", "y"].includes(dryRunRaw);
46
+
47
+ const now = new Date();
48
+ const cutoff = new Date(now.getTime() - inactiveDays * 24 * 60 * 60 * 1000);
49
+ const cutoffDate = cutoff.toISOString().slice(0, 10);
50
+
51
+ core.info(`inactiveDays=${inactiveDays}`);
52
+ core.info(`dryRun=${dryRun}`);
53
+ core.info(`cutoffDate=${cutoffDate}`);
54
+
55
+ const owner = context.repo.owner;
56
+ const repo = context.repo.repo;
57
+
58
+ async function processQuery(kind, searchQuery) {
59
+ core.info(`Searching ${kind}: ${searchQuery}`);
60
+
61
+ const items = await github.paginate(github.rest.search.issuesAndPullRequests, {
62
+ q: searchQuery,
63
+ per_page: 100,
64
+ });
65
+
66
+ if (items.length === 0) {
67
+ core.info(`No inactive ${kind} found.`);
68
+ return;
69
+ }
70
+
71
+ core.info(`Found ${items.length} inactive ${kind}. URLs:`);
72
+ for (const item of items) {
73
+ core.info(item.html_url);
74
+ }
75
+
76
+ if (dryRun) {
77
+ return;
78
+ }
79
+
80
+ for (const item of items) {
81
+ const issueNumber = item.number;
82
+ const url = item.html_url;
83
+
84
+ try {
85
+ await github.rest.issues.createComment({
86
+ owner,
87
+ repo,
88
+ issue_number: issueNumber,
89
+ body: `Closing due to inactivity of ${inactiveDays} days.`,
90
+ });
91
+
92
+ await github.rest.issues.update({
93
+ owner,
94
+ repo,
95
+ issue_number: issueNumber,
96
+ state: "closed",
97
+ });
98
+
99
+ core.info(`Closed: ${url}`);
100
+ } catch (err) {
101
+ core.warning(`Failed to close ${url}: ${err?.message ?? String(err)}`);
102
+ }
103
+ }
104
+ }
105
+
106
+ const base = `repo:${owner}/${repo} is:open updated:<${cutoffDate}`;
107
+ await processQuery("issues", `${base} is:issue`);
108
+ await processQuery("pull requests", `${base} is:pr`);
.github/workflows/docker-publish.yml
new
+236
@@ -0,0 +1,236 @@
1
+name: Build And Publish Docker Images
2
+
3
+on:
4
+ push:
5
+ branches:
6
+ - "testing"
7
+ - "ready"
8
+ - "main"
9
+ tags:
10
+ - "v*"
11
+ workflow_dispatch:
12
+ inputs:
13
+ tag:
14
+ description: "Optional release tag to rebuild, for example v1.21"
15
+ required: false
16
+ type: string
17
+
18
+env:
19
+ # Non-main branches publish a Docker tag with the same name as the branch.
20
+ ALLOWED_BRANCHES: "testing ready main"
21
+ MAIN_BRANCH: "main"
22
+ RELEASE_TAG_REGEX: "^v([0-9]+)\\.([0-9]+)$"
23
+ MIN_RELEASE_MAJOR: "1"
24
+ MIN_RELEASE_MINOR: "0"
25
+ RELEASE_NOTES_DIR: "docs/release_notes"
26
+ DOCKERFILE_DIR: "docker/run"
27
+ DOCKERFILE_PATH: "docker/run/Dockerfile"
28
+ DOCKER_IMAGE_NAME: "agent-zero"
29
+ DOCKER_PLATFORMS: "linux/amd64,linux/arm64"
30
+
31
+permissions:
32
+ contents: read
33
+
34
+jobs:
35
+ plan:
36
+ if: github.repository == 'agent0ai/agent-zero'
37
+ runs-on: ubuntu-latest
38
+ outputs:
39
+ has_work: ${{ steps.plan.outputs.has_work }}
40
+ matrix: ${{ steps.plan.outputs.matrix }}
41
+ steps:
42
+ - name: Validate Docker Hub secrets
43
+ env:
44
+ DOCKERHUB_ORG: ${{ secrets.DOCKERHUB_ORG }}
45
+ DOCKERHUB_OAT_TOKEN: ${{ secrets.DOCKERHUB_OAT_TOKEN }}
46
+ run: |
47
+ if [[ -z "$DOCKERHUB_ORG" || -z "$DOCKERHUB_OAT_TOKEN" ]]; then
48
+ echo "::error::Missing DOCKERHUB_ORG or DOCKERHUB_OAT_TOKEN secret."
49
+ exit 1
50
+ fi
51
+
52
+ - name: Check out repository
53
+ uses: actions/checkout@v4
54
+ with:
55
+ fetch-depth: 0
56
+
57
+ - name: Fetch remote branches and tags
58
+ run: git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*'
59
+
60
+ - name: Set up Docker Buildx
61
+ uses: docker/setup-buildx-action@v3
62
+
63
+ - name: Log in to Docker Hub
64
+ uses: docker/login-action@v3
65
+ with:
66
+ username: ${{ secrets.DOCKERHUB_ORG }}
67
+ password: ${{ secrets.DOCKERHUB_OAT_TOKEN }}
68
+
69
+ - name: Plan Docker publish targets
70
+ id: plan
71
+ env:
72
+ EVENT_NAME: ${{ github.event_name }}
73
+ SOURCE_REF_NAME: ${{ github.ref_name }}
74
+ SOURCE_REF_TYPE: ${{ github.ref_type }}
75
+ BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
76
+ AFTER_SHA: ${{ github.event_name == 'push' && github.sha || '' }}
77
+ MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
78
+ DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
79
+ run: python3 .github/scripts/docker_release_plan.py plan
80
+
81
+ build:
82
+ if: needs.plan.outputs.has_work == 'true'
83
+ needs: plan
84
+ runs-on: ubuntu-latest
85
+ permissions:
86
+ contents: write
87
+ strategy:
88
+ fail-fast: false
89
+ matrix: ${{ fromJson(needs.plan.outputs.matrix) }}
90
+ concurrency:
91
+ group: docker-publish-${{ github.repository }}-${{ matrix.branch }}
92
+ cancel-in-progress: false
93
+ steps:
94
+ - name: Check out repository
95
+ uses: actions/checkout@v4
96
+ with:
97
+ fetch-depth: 0
98
+ ref: ${{ matrix.source_tag }}
99
+
100
+ - name: Fetch remote branches and tags
101
+ run: git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*'
102
+
103
+ - name: Validate Docker Hub secrets
104
+ env:
105
+ DOCKERHUB_ORG: ${{ secrets.DOCKERHUB_ORG }}
106
+ DOCKERHUB_OAT_TOKEN: ${{ secrets.DOCKERHUB_OAT_TOKEN }}
107
+ run: |
108
+ if [[ -z "$DOCKERHUB_ORG" || -z "$DOCKERHUB_OAT_TOKEN" ]]; then
109
+ echo "::error::Missing DOCKERHUB_ORG or DOCKERHUB_OAT_TOKEN secret."
110
+ exit 1
111
+ fi
112
+
113
+ - name: Set up QEMU
114
+ uses: docker/setup-qemu-action@v3
115
+
116
+ - name: Set up Docker Buildx
117
+ uses: docker/setup-buildx-action@v3
118
+
119
+ - name: Log in to Docker Hub
120
+ uses: docker/login-action@v3
121
+ with:
122
+ username: ${{ secrets.DOCKERHUB_ORG }}
123
+ password: ${{ secrets.DOCKERHUB_OAT_TOKEN }}
124
+
125
+ - name: Re-resolve Docker tags for this build
126
+ id: resolve
127
+ env:
128
+ EVENT_NAME: ${{ github.event_name }}
129
+ SOURCE_REF_NAME: ${{ github.ref_name }}
130
+ SOURCE_REF_TYPE: ${{ github.ref_type }}
131
+ BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
132
+ AFTER_SHA: ${{ github.event_name == 'push' && github.sha || '' }}
133
+ MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
134
+ DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
135
+ TARGET_BRANCH: ${{ matrix.branch }}
136
+ TARGET_TAG: ${{ matrix.source_tag }}
137
+ TARGET_MODE: ${{ matrix.mode }}
138
+ TARGET_PUBLISH_VERSION: ${{ matrix.publish_version }}
139
+ TARGET_PUBLISH_BRANCH_TAG: ${{ matrix.publish_branch_tag }}
140
+ run: python3 .github/scripts/docker_release_plan.py resolve-build
141
+
142
+ - name: Skip when target is no longer eligible
143
+ if: steps.resolve.outputs.should_build != 'true'
144
+ run: echo "${{ steps.resolve.outputs.skip_reason }}"
145
+
146
+ - name: Set cache date
147
+ if: steps.resolve.outputs.should_build == 'true'
148
+ id: cache_date
149
+ run: echo "value=$(date -u +%Y-%m-%d:%H:%M:%S)" >> "$GITHUB_OUTPUT"
150
+
151
+ - name: Build and push Docker image
152
+ if: steps.resolve.outputs.should_build == 'true'
153
+ uses: docker/build-push-action@v6
154
+ with:
155
+ context: ${{ env.DOCKERFILE_DIR }}
156
+ file: ${{ env.DOCKERFILE_PATH }}
157
+ platforms: ${{ env.DOCKER_PLATFORMS }}
158
+ push: true
159
+ tags: ${{ steps.resolve.outputs.tags }}
160
+ build-args: |
161
+ BRANCH=${{ matrix.branch }}
162
+ CACHE_DATE=${{ steps.cache_date.outputs.value }}
163
+
164
+ - name: Resolve GitHub release target
165
+ if: steps.resolve.outputs.should_build == 'true'
166
+ id: release_plan
167
+ env:
168
+ EVENT_NAME: ${{ github.event_name }}
169
+ SOURCE_REF_NAME: ${{ github.ref_name }}
170
+ SOURCE_REF_TYPE: ${{ github.ref_type }}
171
+ BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
172
+ AFTER_SHA: ${{ github.event_name == 'push' && github.sha || '' }}
173
+ MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
174
+ DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
175
+ TARGET_BRANCH: ${{ matrix.branch }}
176
+ TARGET_TAG: ${{ matrix.source_tag }}
177
+ RELEASE_NOTES_DIR: ${{ env.RELEASE_NOTES_DIR }}
178
+ run: python3 .github/scripts/docker_release_plan.py resolve-release
179
+
180
+ - name: Skip GitHub release
181
+ if: steps.resolve.outputs.should_build == 'true' && steps.release_plan.outputs.should_release != 'true'
182
+ run: echo "${{ steps.release_plan.outputs.skip_reason }}"
183
+
184
+ - name: Create or update GitHub release
185
+ if: steps.resolve.outputs.should_build == 'true' && steps.release_plan.outputs.should_release == 'true'
186
+ uses: actions/github-script@v7
187
+ env:
188
+ RELEASE_TAG: ${{ steps.release_plan.outputs.release_tag }}
189
+ RELEASE_NAME: ${{ steps.release_plan.outputs.release_name }}
190
+ RELEASE_BODY: ${{ steps.release_plan.outputs.release_body }}
191
+ with:
192
+ script: |
193
+ const owner = context.repo.owner;
194
+ const repo = context.repo.repo;
195
+ const tag = process.env.RELEASE_TAG;
196
+ const name = process.env.RELEASE_NAME;
197
+ const body = process.env.RELEASE_BODY;
198
+
199
+ try {
200
+ const existing = await github.rest.repos.getReleaseByTag({
201
+ owner,
202
+ repo,
203
+ tag,
204
+ });
205
+
206
+ await github.rest.repos.updateRelease({
207
+ owner,
208
+ repo,
209
+ release_id: existing.data.id,
210
+ tag_name: tag,
211
+ name,
212
+ body,
213
+ draft: false,
214
+ prerelease: false,
215
+ make_latest: "true",
216
+ });
217
+
218
+ core.info(`Updated release ${tag}`);
219
+ } catch (error) {
220
+ if (error.status !== 404) {
221
+ throw error;
222
+ }
223
+
224
+ await github.rest.repos.createRelease({
225
+ owner,
226
+ repo,
227
+ tag_name: tag,
228
+ name,
229
+ body,
230
+ draft: false,
231
+ prerelease: false,
232
+ make_latest: "true",
233
+ });
234
+
235
+ core.info(`Created release ${tag}`);
236
+ }