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.