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 + }