Update documentation with release notes workflow and Docker publish automation details

- Add release notes section to AGENTS.md with workflow overview and writing guidelines - Document Docker publish automation in Git Workflow section - Add release_notes/ directory reference to key files list - Update README.md with release notes documentation link and changelog note - Add release notes entry to docs/README.md navigation - Document automated Docker Hub publishing in dev-setup.md - Update AGENTS

frdel committed Mar 25, 2026 at 15:19 UTC 1d6d5497657431fbc5f421602b5c4de1223c5982
7 files changed +778 -2
.github/scripts/docker_release_plan.py new
+514
@@ -0,0 +1,514 @@
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_tag: str
73 + manual_tag: str
74 +
75 +
76 +@dataclass(frozen=True)
77 +class BranchState:
78 + branch: str
79 + valid_tags: list[str]
80 + latest_tag: str | None
81 +
82 +
83 +@dataclass
84 +class Candidate:
85 + branch: str
86 + source_tag: str
87 + mode: str
88 + publish_version: bool
89 + publish_branch_tag: bool
90 + reason: str
91 +
92 +
93 +def load_config() -> Config:
94 + allowed_branches = split_branches(os.environ["ALLOWED_BRANCHES"])
95 + if not allowed_branches:
96 + fail("ALLOWED_BRANCHES must not be empty.")
97 + main_branch = os.environ["MAIN_BRANCH"].strip()
98 + if main_branch not in allowed_branches:
99 + fail("MAIN_BRANCH must also be listed in ALLOWED_BRANCHES.")
100 +
101 + tag_regex = os.environ["RELEASE_TAG_REGEX"]
102 + return Config(
103 + allowed_branches=allowed_branches,
104 + main_branch=main_branch,
105 + image_repo=os.environ["DOCKER_IMAGE_REPO"].strip(),
106 + tag_pattern=re.compile(tag_regex),
107 + min_version=(
108 + int(os.environ["MIN_RELEASE_MAJOR"]),
109 + int(os.environ["MIN_RELEASE_MINOR"]),
110 + ),
111 + event_name=os.environ["EVENT_NAME"].strip(),
112 + source_tag=os.environ.get("SOURCE_TAG", "").strip(),
113 + manual_tag=os.environ.get("MANUAL_TAG", "").strip(),
114 + )
115 +
116 +
117 +def parse_release_tag(config: Config, tag: str) -> tuple[int, int] | None:
118 + match = config.tag_pattern.fullmatch(tag)
119 + if not match:
120 + return None
121 + version = (int(match.group(1)), int(match.group(2)))
122 + if version < config.min_version:
123 + return None
124 + return version
125 +
126 +
127 +def tag_exists(tag: str) -> bool:
128 + return run_command("git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}", check=False).returncode == 0
129 +
130 +
131 +def tag_commit(tag: str) -> str:
132 + return git("rev-list", "-n", "1", f"refs/tags/{tag}")
133 +
134 +
135 +def branch_contains_commit(branch: str, commit: str) -> bool:
136 + return (
137 + run_command(
138 + "git",
139 + "merge-base",
140 + "--is-ancestor",
141 + commit,
142 + f"origin/{branch}",
143 + check=False,
144 + ).returncode
145 + == 0
146 + )
147 +
148 +
149 +def collect_branch_states(config: Config, branches: list[str] | None = None) -> dict[str, BranchState]:
150 + states: dict[str, BranchState] = {}
151 + for branch in branches or config.allowed_branches:
152 + if run_command("git", "show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}", check=False).returncode != 0:
153 + fail(f"Allowed branch origin/{branch} was not fetched.")
154 +
155 + tagged_versions: list[tuple[tuple[int, int], str]] = []
156 + merged_tags = git("tag", "--merged", f"origin/{branch}")
157 + for tag in merged_tags.splitlines():
158 + version = parse_release_tag(config, tag.strip())
159 + if version is None:
160 + continue
161 + tagged_versions.append((version, tag.strip()))
162 +
163 + tagged_versions.sort(key=lambda item: item[0])
164 + valid_tags = [tag for _, tag in tagged_versions]
165 + states[branch] = BranchState(
166 + branch=branch,
167 + valid_tags=valid_tags,
168 + latest_tag=valid_tags[-1] if valid_tags else None,
169 + )
170 + return states
171 +
172 +
173 +def add_or_merge_candidate(candidates: dict[tuple[str, str, str], Candidate], candidate: Candidate) -> None:
174 + key = (candidate.branch, candidate.source_tag, candidate.mode)
175 + existing = candidates.get(key)
176 + if existing is None:
177 + candidates[key] = candidate
178 + return
179 + existing.publish_version = existing.publish_version or candidate.publish_version
180 + existing.publish_branch_tag = existing.publish_branch_tag or candidate.publish_branch_tag
181 + if candidate.reason not in existing.reason:
182 + existing.reason = f"{existing.reason}; {candidate.reason}"
183 +
184 +
185 +def plan_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
186 + source_tag = config.source_tag
187 + notes: list[str] = []
188 + version = parse_release_tag(config, source_tag)
189 + if version is None:
190 + 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]}."]
191 + if not tag_exists(source_tag):
192 + return [], [f"Skipped `{source_tag}` because the tag is not present after checkout."]
193 +
194 + commit = tag_commit(source_tag)
195 + candidates: list[Candidate] = []
196 + found_branch = False
197 + for branch, state in branch_states.items():
198 + if not branch_contains_commit(branch, commit):
199 + continue
200 + found_branch = True
201 + if state.latest_tag != source_tag:
202 + notes.append(
203 + f"Skipped `{source_tag}` on `{branch}` because `{state.latest_tag}` is the highest release tag currently reachable from that branch."
204 + )
205 + continue
206 + candidates.append(
207 + Candidate(
208 + branch=branch,
209 + source_tag=source_tag,
210 + mode="push_latest_only",
211 + publish_version=branch == config.main_branch,
212 + publish_branch_tag=True,
213 + reason=f"Automatic build for the latest release tag on `{branch}`.",
214 + )
215 + )
216 +
217 + if not found_branch:
218 + notes.append(f"Skipped `{source_tag}` because it is not reachable from any allowed branch.")
219 + return candidates, notes
220 +
221 +
222 +def plan_manual_exact(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
223 + manual_tag = config.manual_tag
224 + if parse_release_tag(config, manual_tag) is None:
225 + fail(
226 + f"Manual tag `{manual_tag}` is invalid. Expected `v{{X}}.{{Y}}` with a minimum of v{config.min_version[0]}.{config.min_version[1]}."
227 + )
228 + if not tag_exists(manual_tag):
229 + fail(f"Manual tag `{manual_tag}` does not exist in the repository.")
230 +
231 + commit = tag_commit(manual_tag)
232 + notes: list[str] = []
233 + candidates: list[Candidate] = []
234 + for branch, state in branch_states.items():
235 + if not branch_contains_commit(branch, commit):
236 + continue
237 + if branch == config.main_branch:
238 + candidates.append(
239 + Candidate(
240 + branch=branch,
241 + source_tag=manual_tag,
242 + mode="manual_exact",
243 + publish_version=True,
244 + publish_branch_tag=state.latest_tag == manual_tag,
245 + reason=f"Manual rebuild for `{manual_tag}` on `{branch}`.",
246 + )
247 + )
248 + continue
249 + if state.latest_tag != manual_tag:
250 + notes.append(
251 + f"Skipped `{manual_tag}` on `{branch}` because non-main branches only publish their current branch tag and `{state.latest_tag}` is newer."
252 + )
253 + continue
254 + candidates.append(
255 + Candidate(
256 + branch=branch,
257 + source_tag=manual_tag,
258 + mode="manual_exact",
259 + publish_version=False,
260 + publish_branch_tag=True,
261 + reason=f"Manual rebuild for the current branch image on `{branch}`.",
262 + )
263 + )
264 +
265 + if not candidates:
266 + notes.append(f"No eligible images were found for manual tag `{manual_tag}`.")
267 + return candidates, notes
268 +
269 +
270 +def plan_manual_backfill(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]:
271 + notes: list[str] = []
272 + candidates: dict[tuple[str, str, str], Candidate] = {}
273 +
274 + for branch, state in branch_states.items():
275 + if not state.valid_tags:
276 + notes.append(f"Branch `{branch}` has no releasable tags.")
277 + continue
278 +
279 + if branch == config.main_branch:
280 + for tag in state.valid_tags:
281 + if docker_tag_exists(config.image_repo, tag):
282 + continue
283 + add_or_merge_candidate(
284 + candidates,
285 + Candidate(
286 + branch=branch,
287 + source_tag=tag,
288 + mode="manual_backfill",
289 + publish_version=True,
290 + publish_branch_tag=False,
291 + reason=f"Missing Docker Hub tag `{tag}`.",
292 + ),
293 + )
294 +
295 + latest_tag = state.latest_tag
296 + if latest_tag and not docker_tag_exists(config.image_repo, "latest"):
297 + add_or_merge_candidate(
298 + candidates,
299 + Candidate(
300 + branch=branch,
301 + source_tag=latest_tag,
302 + mode="manual_backfill",
303 + publish_version=False,
304 + publish_branch_tag=True,
305 + reason="Missing Docker Hub tag `latest`.",
306 + ),
307 + )
308 + continue
309 +
310 + if not docker_tag_exists(config.image_repo, branch):
311 + add_or_merge_candidate(
312 + candidates,
313 + Candidate(
314 + branch=branch,
315 + source_tag=state.latest_tag,
316 + mode="manual_backfill",
317 + publish_version=False,
318 + publish_branch_tag=True,
319 + reason=f"Missing Docker Hub tag `{branch}`.",
320 + ),
321 + )
322 +
323 + if not candidates:
324 + notes.append("No missing Docker Hub tags were found.")
325 + return list(candidates.values()), notes
326 +
327 +
328 +def plan_command() -> None:
329 + config = load_config()
330 + branch_states = collect_branch_states(config)
331 +
332 + if config.event_name == "workflow_dispatch":
333 + if config.manual_tag:
334 + candidates, notes = plan_manual_exact(config, branch_states)
335 + else:
336 + candidates, notes = plan_manual_backfill(config, branch_states)
337 + elif config.event_name == "push":
338 + candidates, notes = plan_push(config, branch_states)
339 + else:
340 + fail(f"Unsupported event: {config.event_name}")
341 +
342 + summary_lines = [candidate.reason for candidate in candidates]
343 + summary_lines.extend(notes)
344 +
345 + matrix = {"include": [asdict(candidate) for candidate in candidates]}
346 + write_output("has_work", "true" if candidates else "false")
347 + write_output("matrix", json.dumps(matrix))
348 + write_summary(summary_lines)
349 +
350 + print(json.dumps(matrix, indent=2))
351 + for line in summary_lines:
352 + print(f"- {line}")
353 +
354 +
355 +def unique(items: list[str]) -> list[str]:
356 + seen: set[str] = set()
357 + output: list[str] = []
358 + for item in items:
359 + if item in seen:
360 + continue
361 + seen.add(item)
362 + output.append(item)
363 + return output
364 +
365 +
366 +def resolve_release_command() -> None:
367 + config = load_config()
368 + branch = os.environ["TARGET_BRANCH"].strip()
369 + source_tag = os.environ["TARGET_TAG"].strip()
370 + notes_dir = os.environ["RELEASE_NOTES_DIR"].strip()
371 +
372 + if branch != config.main_branch:
373 + write_output("should_release", "false")
374 + write_output("skip_reason", f"Branch `{branch}` does not publish GitHub releases.")
375 + return
376 +
377 + branch_state = collect_branch_states(config, [branch])[branch]
378 + if branch_state.latest_tag is None:
379 + write_output("should_release", "false")
380 + write_output("skip_reason", f"Branch `{branch}` has no releasable tags.")
381 + return
382 +
383 + if parse_release_tag(config, source_tag) is None or not tag_exists(source_tag):
384 + write_output("should_release", "false")
385 + write_output("skip_reason", f"Tag `{source_tag}` is not a releasable tag.")
386 + return
387 +
388 + commit = tag_commit(source_tag)
389 + if not branch_contains_commit(branch, commit):
390 + write_output("should_release", "false")
391 + write_output("skip_reason", f"Tag `{source_tag}` is no longer reachable from `{branch}`.")
392 + return
393 +
394 + if branch_state.latest_tag != source_tag:
395 + write_output("should_release", "false")
396 + write_output(
397 + "skip_reason",
398 + f"Tag `{source_tag}` is not the highest release tag on `{branch}`.",
399 + )
400 + return
401 +
402 + notes_path = os.path.join(notes_dir, f"{source_tag}.md")
403 + if not os.path.exists(notes_path):
404 + fail(
405 + f"Expected release notes file `{notes_path}` for GitHub release `{source_tag}`."
406 + )
407 +
408 + with open(notes_path, "r", encoding="utf-8") as handle:
409 + body = handle.read().strip()
410 +
411 + write_output("should_release", "true")
412 + write_output("release_tag", source_tag)
413 + write_output("release_name", source_tag)
414 + write_output("release_notes_path", notes_path)
415 + write_output("release_body", body or "No release notes.")
416 + print(source_tag)
417 +
418 +
419 +def resolve_build_command() -> None:
420 + config = load_config()
421 + branch = os.environ["TARGET_BRANCH"].strip()
422 + source_tag = os.environ["TARGET_TAG"].strip()
423 + mode = os.environ["TARGET_MODE"].strip()
424 + publish_version = os.environ["TARGET_PUBLISH_VERSION"].strip().lower() == "true"
425 + publish_branch_tag = os.environ["TARGET_PUBLISH_BRANCH_TAG"].strip().lower() == "true"
426 +
427 + branch_state = collect_branch_states(config, [branch])[branch]
428 + if branch_state.latest_tag is None:
429 + write_output("should_build", "false")
430 + write_output("skip_reason", f"Branch `{branch}` has no releasable tags.")
431 + return
432 +
433 + if parse_release_tag(config, source_tag) is None or not tag_exists(source_tag):
434 + write_output("should_build", "false")
435 + write_output("skip_reason", f"Tag `{source_tag}` is no longer available.")
436 + return
437 +
438 + commit = tag_commit(source_tag)
439 + if not branch_contains_commit(branch, commit):
440 + write_output("should_build", "false")
441 + write_output("skip_reason", f"Tag `{source_tag}` is no longer reachable from `{branch}`.")
442 + return
443 +
444 + mutable_tag = "latest" if branch == config.main_branch else branch
445 + tags_to_push: list[str] = []
446 +
447 + if mode == "push_latest_only":
448 + if branch_state.latest_tag != source_tag:
449 + write_output("should_build", "false")
450 + write_output(
451 + "skip_reason",
452 + f"Tag `{source_tag}` is no longer the highest release tag on `{branch}`.",
453 + )
454 + return
455 + if publish_version:
456 + tags_to_push.append(f"{config.image_repo}:{source_tag}")
457 + if publish_branch_tag:
458 + tags_to_push.append(f"{config.image_repo}:{mutable_tag}")
459 +
460 + elif mode == "manual_exact":
461 + if publish_version:
462 + tags_to_push.append(f"{config.image_repo}:{source_tag}")
463 + if publish_branch_tag and branch_state.latest_tag == source_tag:
464 + tags_to_push.append(f"{config.image_repo}:{mutable_tag}")
465 +
466 + elif mode == "manual_backfill":
467 + if publish_version and not docker_tag_exists(config.image_repo, source_tag):
468 + tags_to_push.append(f"{config.image_repo}:{source_tag}")
469 + if publish_branch_tag:
470 + if branch != config.main_branch and branch_state.latest_tag != source_tag:
471 + write_output("should_build", "false")
472 + write_output(
473 + "skip_reason",
474 + f"Tag `{source_tag}` is no longer the newest release tag on `{branch}`.",
475 + )
476 + return
477 + if branch == config.main_branch and branch_state.latest_tag != source_tag:
478 + publish_branch_tag = False
479 + if publish_branch_tag and not docker_tag_exists(config.image_repo, mutable_tag):
480 + tags_to_push.append(f"{config.image_repo}:{mutable_tag}")
481 + else:
482 + fail(f"Unsupported resolve-build mode: {mode}")
483 +
484 + tags_to_push = unique(tags_to_push)
485 + if not tags_to_push:
486 + write_output("should_build", "false")
487 + write_output("skip_reason", "All requested Docker tags already exist or are no longer eligible.")
488 + return
489 +
490 + write_output("should_build", "true")
491 + write_output("tags", "\n".join(tags_to_push))
492 + write_output("display_tags", ", ".join(tag.rsplit(":", 1)[1] for tag in tags_to_push))
493 + print("\n".join(tags_to_push))
494 +
495 +
496 +def main() -> None:
497 + if len(sys.argv) != 2:
498 + fail("Usage: docker_release_plan.py <plan|resolve-build|resolve-release>")
499 +
500 + command = sys.argv[1]
501 + if command == "plan":
502 + plan_command()
503 + return
504 + if command == "resolve-build":
505 + resolve_build_command()
506 + return
507 + if command == "resolve-release":
508 + resolve_release_command()
509 + return
510 + fail(f"Unknown command: {command}")
511 +
512 +
513 +if __name__ == "__main__":
514 + main()
.github/workflows/docker-publish.yml new
+222
@@ -0,0 +1,222 @@
1 +name: Build And Publish Docker Images
2 +
3 +on:
4 + push:
5 + tags:
6 + - "v*"
7 + workflow_dispatch:
8 + inputs:
9 + tag:
10 + description: "Optional release tag to rebuild, for example v1.21"
11 + required: false
12 + type: string
13 +
14 +env:
15 + # Non-main branches publish a Docker tag with the same name as the branch.
16 + ALLOWED_BRANCHES: "testing main"
17 + MAIN_BRANCH: "main"
18 + RELEASE_TAG_REGEX: "^v([0-9]+)\\.([0-9]+)$"
19 + MIN_RELEASE_MAJOR: "1"
20 + MIN_RELEASE_MINOR: "0"
21 + RELEASE_NOTES_DIR: "docs/release_notes"
22 + DOCKERFILE_DIR: "docker/run"
23 + DOCKERFILE_PATH: "docker/run/Dockerfile"
24 + DOCKER_IMAGE_NAME: "agent-zero"
25 + DOCKER_PLATFORMS: "linux/amd64,linux/arm64"
26 +
27 +permissions:
28 + contents: read
29 +
30 +jobs:
31 + plan:
32 + if: github.repository == 'agent0ai/agent-zero'
33 + runs-on: ubuntu-latest
34 + outputs:
35 + has_work: ${{ steps.plan.outputs.has_work }}
36 + matrix: ${{ steps.plan.outputs.matrix }}
37 + steps:
38 + - name: Validate Docker Hub secrets
39 + env:
40 + DOCKERHUB_ORG: ${{ secrets.DOCKERHUB_ORG }}
41 + DOCKERHUB_OAT_TOKEN: ${{ secrets.DOCKERHUB_OAT_TOKEN }}
42 + run: |
43 + if [[ -z "$DOCKERHUB_ORG" || -z "$DOCKERHUB_OAT_TOKEN" ]]; then
44 + echo "::error::Missing DOCKERHUB_ORG or DOCKERHUB_OAT_TOKEN secret."
45 + exit 1
46 + fi
47 +
48 + - name: Check out repository
49 + uses: actions/checkout@v4
50 + with:
51 + fetch-depth: 0
52 +
53 + - name: Fetch remote branches and tags
54 + run: git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*'
55 +
56 + - name: Set up Docker Buildx
57 + uses: docker/setup-buildx-action@v3
58 +
59 + - name: Log in to Docker Hub
60 + uses: docker/login-action@v3
61 + with:
62 + username: ${{ secrets.DOCKERHUB_ORG }}
63 + password: ${{ secrets.DOCKERHUB_OAT_TOKEN }}
64 +
65 + - name: Plan Docker publish targets
66 + id: plan
67 + env:
68 + EVENT_NAME: ${{ github.event_name }}
69 + SOURCE_TAG: ${{ github.ref_name }}
70 + MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
71 + DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
72 + run: python3 .github/scripts/docker_release_plan.py plan
73 +
74 + build:
75 + if: needs.plan.outputs.has_work == 'true'
76 + needs: plan
77 + runs-on: ubuntu-latest
78 + permissions:
79 + contents: write
80 + strategy:
81 + fail-fast: false
82 + matrix: ${{ fromJson(needs.plan.outputs.matrix) }}
83 + concurrency:
84 + group: docker-publish-${{ github.repository }}-${{ matrix.branch }}
85 + cancel-in-progress: false
86 + steps:
87 + - name: Check out repository
88 + uses: actions/checkout@v4
89 + with:
90 + fetch-depth: 0
91 +
92 + - name: Fetch remote branches and tags
93 + run: git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*'
94 +
95 + - name: Validate Docker Hub secrets
96 + env:
97 + DOCKERHUB_ORG: ${{ secrets.DOCKERHUB_ORG }}
98 + DOCKERHUB_OAT_TOKEN: ${{ secrets.DOCKERHUB_OAT_TOKEN }}
99 + run: |
100 + if [[ -z "$DOCKERHUB_ORG" || -z "$DOCKERHUB_OAT_TOKEN" ]]; then
101 + echo "::error::Missing DOCKERHUB_ORG or DOCKERHUB_OAT_TOKEN secret."
102 + exit 1
103 + fi
104 +
105 + - name: Set up QEMU
106 + uses: docker/setup-qemu-action@v3
107 +
108 + - name: Set up Docker Buildx
109 + uses: docker/setup-buildx-action@v3
110 +
111 + - name: Log in to Docker Hub
112 + uses: docker/login-action@v3
113 + with:
114 + username: ${{ secrets.DOCKERHUB_ORG }}
115 + password: ${{ secrets.DOCKERHUB_OAT_TOKEN }}
116 +
117 + - name: Re-resolve Docker tags for this build
118 + id: resolve
119 + env:
120 + EVENT_NAME: ${{ github.event_name }}
121 + SOURCE_TAG: ${{ github.ref_name }}
122 + MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
123 + DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
124 + TARGET_BRANCH: ${{ matrix.branch }}
125 + TARGET_TAG: ${{ matrix.source_tag }}
126 + TARGET_MODE: ${{ matrix.mode }}
127 + TARGET_PUBLISH_VERSION: ${{ matrix.publish_version }}
128 + TARGET_PUBLISH_BRANCH_TAG: ${{ matrix.publish_branch_tag }}
129 + run: python3 .github/scripts/docker_release_plan.py resolve-build
130 +
131 + - name: Skip when target is no longer eligible
132 + if: steps.resolve.outputs.should_build != 'true'
133 + run: echo "${{ steps.resolve.outputs.skip_reason }}"
134 +
135 + - name: Set cache date
136 + if: steps.resolve.outputs.should_build == 'true'
137 + id: cache_date
138 + run: echo "value=$(date -u +%Y-%m-%d:%H:%M:%S)" >> "$GITHUB_OUTPUT"
139 +
140 + - name: Build and push Docker image
141 + if: steps.resolve.outputs.should_build == 'true'
142 + uses: docker/build-push-action@v6
143 + with:
144 + context: ${{ env.DOCKERFILE_DIR }}
145 + file: ${{ env.DOCKERFILE_PATH }}
146 + platforms: ${{ env.DOCKER_PLATFORMS }}
147 + push: true
148 + tags: ${{ steps.resolve.outputs.tags }}
149 + build-args: |
150 + BRANCH=${{ matrix.branch }}
151 + CACHE_DATE=${{ steps.cache_date.outputs.value }}
152 +
153 + - name: Resolve GitHub release target
154 + if: steps.resolve.outputs.should_build == 'true'
155 + id: release_plan
156 + env:
157 + EVENT_NAME: ${{ github.event_name }}
158 + SOURCE_TAG: ${{ github.ref_name }}
159 + MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }}
160 + DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }}
161 + TARGET_BRANCH: ${{ matrix.branch }}
162 + TARGET_TAG: ${{ matrix.source_tag }}
163 + RELEASE_NOTES_DIR: ${{ env.RELEASE_NOTES_DIR }}
164 + run: python3 .github/scripts/docker_release_plan.py resolve-release
165 +
166 + - name: Skip GitHub release
167 + if: steps.resolve.outputs.should_build == 'true' && steps.release_plan.outputs.should_release != 'true'
168 + run: echo "${{ steps.release_plan.outputs.skip_reason }}"
169 +
170 + - name: Create or update GitHub release
171 + if: steps.resolve.outputs.should_build == 'true' && steps.release_plan.outputs.should_release == 'true'
172 + uses: actions/github-script@v7
173 + env:
174 + RELEASE_TAG: ${{ steps.release_plan.outputs.release_tag }}
175 + RELEASE_NAME: ${{ steps.release_plan.outputs.release_name }}
176 + RELEASE_BODY: ${{ steps.release_plan.outputs.release_body }}
177 + with:
178 + script: |
179 + const owner = context.repo.owner;
180 + const repo = context.repo.repo;
181 + const tag = process.env.RELEASE_TAG;
182 + const name = process.env.RELEASE_NAME;
183 + const body = process.env.RELEASE_BODY;
184 +
185 + try {
186 + const existing = await github.rest.repos.getReleaseByTag({
187 + owner,
188 + repo,
189 + tag,
190 + });
191 +
192 + await github.rest.repos.updateRelease({
193 + owner,
194 + repo,
195 + release_id: existing.data.id,
196 + tag_name: tag,
197 + name,
198 + body,
199 + draft: false,
200 + prerelease: false,
201 + make_latest: "true",
202 + });
203 +
204 + core.info(`Updated release ${tag}`);
205 + } catch (error) {
206 + if (error.status !== 404) {
207 + throw error;
208 + }
209 +
210 + await github.rest.repos.createRelease({
211 + owner,
212 + repo,
213 + tag_name: tag,
214 + name,
215 + body,
216 + draft: false,
217 + prerelease: false,
218 + make_latest: "true",
219 + });
220 +
221 + core.info(`Created release ${tag}`);
222 + }
AGENTS.md
+27 -2
@@ -20,7 +20,7 @@ Frontend Deep Dives: [Component System](docs/agents/AGENTS.components.md) | [Mod
20 6. [Safety and Permissions](#safety-and-permissions)
21 7. [Code Examples](#code-examples)
22 8. [Git Workflow](#git-workflow)
23 -9. [API Documentation](#api-documentation)
23 +9. [Release Notes](#release-notes)
24 10. [Troubleshooting](#troubleshooting)
25
26 ---
@@ -103,6 +103,7 @@ Key Files:
103 - python/helpers/plugins.py: Plugin discovery and configuration logic.
104 - webui/js/AlpineStore.js: Store factory for reactive frontend state.
105 - python/helpers/api.py: Base class for all API endpoints.
106 +- docs/release_notes/: Markdown files used by the release workflow to populate GitHub releases for the latest `main` tag.
107 - knowledge/main/about/: Agent self-knowledge files, indexed into the vector DB for runtime recall. Not user-facing docs - written for the agent's internal reference.
108 - docs/agents/AGENTS.components.md: Deep dive into the frontend component architecture.
109 - docs/agents/AGENTS.modals.md: Guide to the stacked modal system.
@@ -144,6 +145,15 @@ Key Files:
145 - Activation: Global and scoped activation rules are stored as .toggle-1 (ON) and .toggle-0 (OFF). Scoped rules are handled via the plugin "Switch" modal.
146 - Cleanup rule: Plugins should not permanently modify the system in ways that outlive the plugin. Deleting a plugin should not leave behind symlinks, unmanaged services, or stray files outside plugin-owned paths unless the user explicitly requested that behavior.
147
148 +### Releases
149 +- Docker publishing automation lives in `.github/workflows/docker-publish.yml`.
150 +- Releasable tags follow `v{X}.{Y}` and only tags `>= v1.0` are considered by the workflow.
151 +- The latest eligible tag on `main` also creates or updates a GitHub release after the Docker image push succeeds.
152 +- Release notes live in `docs/release_notes/<tag>.md`.
153 +- When asked to prepare release notes, compare the repo changes against the previous release notes tag in `docs/release_notes/` and write a concise Markdown summary of the meaningful changes since that release.
154 +- Prioritize user-visible features, important fixes, infra or packaging changes, and breaking notes. Skip low-signal churn.
155 +- If no notes are needed, an empty `docs/release_notes/<tag>.md` is valid and publishes `No release notes.`
156 +
157 ### Lifecycle Synchronization
158 | Action | Backend Extension | Frontend Lifecycle |
159 |---|---|---|
@@ -209,6 +219,21 @@ class MyTool(Tool):
219
220 ---
221
222 +## Git Workflow
223 +
224 +- Docker publish automation lives in `.github/workflows/docker-publish.yml`.
225 +- Release tags handled by automation must match `vX.Y` and be `>= v1.0`.
226 +- Allowed release branches are configured at the top of the workflow. `main` publishes `<tag>` and `latest`; other allowed branches publish only the branch tag.
227 +- Manual dispatch accepts an optional tag. Without a tag it backfills missing Docker Hub tags. With a tag it rebuilds that exact target and only refreshes `latest` and the GitHub release when that tag is still the newest eligible tag on `main`.
228 +
229 +---
230 +
231 +## Release Notes
232 +
233 +- Store release notes in `docs/release_notes/` as `vX.Y.md`.
234 +- Keep them concise and summarize changes since the previous release notes tag.
235 +- The latest eligible `main` tag uses that file for the GitHub release body after Docker publish succeeds.
236 +
237 ## Troubleshooting
238
239 ### Dependency Conflicts
@@ -226,5 +251,5 @@ pip install -r requirements2.txt
251
252 ---
253
229 -*Last updated: 2026-02-22*
254 +*Last updated: 2026-03-25*
255 *Maintained by: Agent Zero Core Team*
README.md
+3
@@ -170,10 +170,13 @@ docker run -p 50001:80 agent0ai/agent-zero
170 | [Architecture](./docs/developer/architecture.md) | System design and components |
171 | [Contributing](./docs/guides/contribution.md) | How to contribute |
172 | [Troubleshooting](./docs/guides/troubleshooting.md) | Common issues and their solutions |
173 +| [Release Notes](./docs/release_notes/README.md) | Release note format used by the automated Docker and GitHub release workflow |
174
175
176 ## 🎯 Changelog
177
178 +New release-note files for current releases live in [docs/release_notes](./docs/release_notes/README.md). The latest eligible `main` tag uses `docs/release_notes/vX.Y.md` for the GitHub release body.
179 +
180 ### v0.9.8 - Skills, UI Redesign & Git projects
181 [Release video](https://youtu.be/NV7s78yn6DY)
182
docs/README.md
+1
@@ -30,6 +30,7 @@ Welcome to the Agent Zero documentation hub. Whether you're getting started or d
30 - **[Notifications](developer/notifications.md):** Notification system architecture and setup.
31 - **[Contributing Skills](developer/contributing-skills.md):** Create and share agent skills.
32 - **[Contributing Guide](guides/contribution.md):** Contribute to the Agent Zero project.
33 +- **[Release Notes](release_notes/README.md):** File format and process used by the automated Docker and GitHub release workflow.
34
35 ## Community & Support
36
docs/release_notes/README.md new
+10
@@ -0,0 +1,10 @@
1 +# Release Notes
2 +
3 +Create one file per release tag in this folder using the exact name `vX.Y.md`, for example `v2.33.md`.
4 +
5 +Rules:
6 +- The automated Docker publish workflow reads `docs/release_notes/<tag>.md` when the current latest `main` release tag is built successfully.
7 +- Keep the notes concise and release-ready. Summarize the meaningful changes since the previous release notes tag in this folder.
8 +- Prefer user-facing features, major fixes, notable infrastructure or packaging changes, and breaking or migration notes. Skip low-signal internal churn.
9 +- Use normal Markdown. A short heading plus a flat bullet list is enough.
10 +- If you intentionally want a release with no notes, leave the file empty and the workflow will publish `No release notes.`
docs/setup/dev-setup.md
+1
@@ -174,3 +174,4 @@ These environment variables automatically override the hardcoded defaults in `ge
174 - Navigate to your project root in the terminal and run `docker build -f DockerfileLocal -t agent-zero-local --build-arg CACHE_DATE=$(date +%Y-%m-%d:%H:%M:%S) .`
175 - The `CACHE_DATE` argument is optional, it is used to cache most of the build process and only rebuild the last steps when the files or dependencies change.
176 - See `docker/run/build.txt` for more build command examples.
177 +- Automated Docker Hub publishing for release tags is handled by `.github/workflows/docker-publish.yml`. Latest `main` releases also read `docs/release_notes/vX.Y.md` to create the GitHub release body.