From 1d6d5497657431fbc5f421602b5c4de1223c5982 Mon Sep 17 00:00:00 2001 From: frdel <38891707+frdel@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:19:08 +0100 Subject: [PATCH] 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 --- .github/scripts/docker_release_plan.py | 514 +++++++++++++++++++++++++ .github/workflows/docker-publish.yml | 222 +++++++++++ AGENTS.md | 29 +- README.md | 3 + docs/README.md | 1 + docs/release_notes/README.md | 10 + docs/setup/dev-setup.md | 1 + 7 files changed, 778 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/docker_release_plan.py create mode 100644 .github/workflows/docker-publish.yml create mode 100644 docs/release_notes/README.md diff --git a/.github/scripts/docker_release_plan.py b/.github/scripts/docker_release_plan.py new file mode 100644 index 000000000..92e715edb --- /dev/null +++ b/.github/scripts/docker_release_plan.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from dataclasses import asdict, dataclass + + +def fail(message: str) -> None: + print(message, file=sys.stderr) + raise SystemExit(1) + + +def write_output(name: str, value: str) -> None: + output_path = os.environ.get("GITHUB_OUTPUT") + if not output_path: + return + with open(output_path, "a", encoding="utf-8") as handle: + handle.write(f"{name}<<__EOF__\n{value}\n__EOF__\n") + + +def write_summary(lines: list[str]) -> None: + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path or not lines: + return + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write("## Docker publish plan\n\n") + for line in lines: + handle.write(f"- {line}\n") + + +def run_command(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run(args, capture_output=True, text=True) + if check and result.returncode != 0: + command = " ".join(args) + fail(f"Command failed ({command}):\n{result.stderr.strip()}") + return result + + +def git(*args: str, check: bool = True) -> str: + return run_command("git", *args, check=check).stdout.strip() + + +def docker_tag_exists(image_repo: str, tag: str) -> bool: + result = run_command( + "docker", + "buildx", + "imagetools", + "inspect", + f"{image_repo}:{tag}", + check=False, + ) + return result.returncode == 0 + + +def split_branches(raw: str) -> list[str]: + parts = re.split(r"[\s,]+", raw.strip()) + return [part for part in parts if part] + + +@dataclass(frozen=True) +class Config: + allowed_branches: list[str] + main_branch: str + image_repo: str + tag_pattern: re.Pattern[str] + min_version: tuple[int, int] + event_name: str + source_tag: str + manual_tag: str + + +@dataclass(frozen=True) +class BranchState: + branch: str + valid_tags: list[str] + latest_tag: str | None + + +@dataclass +class Candidate: + branch: str + source_tag: str + mode: str + publish_version: bool + publish_branch_tag: bool + reason: str + + +def load_config() -> Config: + allowed_branches = split_branches(os.environ["ALLOWED_BRANCHES"]) + if not allowed_branches: + fail("ALLOWED_BRANCHES must not be empty.") + main_branch = os.environ["MAIN_BRANCH"].strip() + if main_branch not in allowed_branches: + fail("MAIN_BRANCH must also be listed in ALLOWED_BRANCHES.") + + tag_regex = os.environ["RELEASE_TAG_REGEX"] + return Config( + allowed_branches=allowed_branches, + main_branch=main_branch, + image_repo=os.environ["DOCKER_IMAGE_REPO"].strip(), + tag_pattern=re.compile(tag_regex), + min_version=( + int(os.environ["MIN_RELEASE_MAJOR"]), + int(os.environ["MIN_RELEASE_MINOR"]), + ), + event_name=os.environ["EVENT_NAME"].strip(), + source_tag=os.environ.get("SOURCE_TAG", "").strip(), + manual_tag=os.environ.get("MANUAL_TAG", "").strip(), + ) + + +def parse_release_tag(config: Config, tag: str) -> tuple[int, int] | None: + match = config.tag_pattern.fullmatch(tag) + if not match: + return None + version = (int(match.group(1)), int(match.group(2))) + if version < config.min_version: + return None + return version + + +def tag_exists(tag: str) -> bool: + return run_command("git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}", check=False).returncode == 0 + + +def tag_commit(tag: str) -> str: + return git("rev-list", "-n", "1", f"refs/tags/{tag}") + + +def branch_contains_commit(branch: str, commit: str) -> bool: + return ( + run_command( + "git", + "merge-base", + "--is-ancestor", + commit, + f"origin/{branch}", + check=False, + ).returncode + == 0 + ) + + +def collect_branch_states(config: Config, branches: list[str] | None = None) -> dict[str, BranchState]: + states: dict[str, BranchState] = {} + for branch in branches or config.allowed_branches: + if run_command("git", "show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}", check=False).returncode != 0: + fail(f"Allowed branch origin/{branch} was not fetched.") + + tagged_versions: list[tuple[tuple[int, int], str]] = [] + merged_tags = git("tag", "--merged", f"origin/{branch}") + for tag in merged_tags.splitlines(): + version = parse_release_tag(config, tag.strip()) + if version is None: + continue + tagged_versions.append((version, tag.strip())) + + tagged_versions.sort(key=lambda item: item[0]) + valid_tags = [tag for _, tag in tagged_versions] + states[branch] = BranchState( + branch=branch, + valid_tags=valid_tags, + latest_tag=valid_tags[-1] if valid_tags else None, + ) + return states + + +def add_or_merge_candidate(candidates: dict[tuple[str, str, str], Candidate], candidate: Candidate) -> None: + key = (candidate.branch, candidate.source_tag, candidate.mode) + existing = candidates.get(key) + if existing is None: + candidates[key] = candidate + return + existing.publish_version = existing.publish_version or candidate.publish_version + existing.publish_branch_tag = existing.publish_branch_tag or candidate.publish_branch_tag + if candidate.reason not in existing.reason: + existing.reason = f"{existing.reason}; {candidate.reason}" + + +def plan_push(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]: + source_tag = config.source_tag + notes: list[str] = [] + version = parse_release_tag(config, source_tag) + if version is None: + 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]}."] + if not tag_exists(source_tag): + return [], [f"Skipped `{source_tag}` because the tag is not present after checkout."] + + commit = tag_commit(source_tag) + candidates: list[Candidate] = [] + found_branch = False + for branch, state in branch_states.items(): + if not branch_contains_commit(branch, commit): + continue + found_branch = True + if state.latest_tag != source_tag: + notes.append( + f"Skipped `{source_tag}` on `{branch}` because `{state.latest_tag}` is the highest release tag currently reachable from that branch." + ) + continue + candidates.append( + Candidate( + branch=branch, + source_tag=source_tag, + mode="push_latest_only", + publish_version=branch == config.main_branch, + publish_branch_tag=True, + reason=f"Automatic build for the latest release tag on `{branch}`.", + ) + ) + + if not found_branch: + notes.append(f"Skipped `{source_tag}` because it is not reachable from any allowed branch.") + return candidates, notes + + +def plan_manual_exact(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]: + manual_tag = config.manual_tag + if parse_release_tag(config, manual_tag) is None: + fail( + f"Manual tag `{manual_tag}` is invalid. Expected `v{{X}}.{{Y}}` with a minimum of v{config.min_version[0]}.{config.min_version[1]}." + ) + if not tag_exists(manual_tag): + fail(f"Manual tag `{manual_tag}` does not exist in the repository.") + + commit = tag_commit(manual_tag) + notes: list[str] = [] + candidates: list[Candidate] = [] + for branch, state in branch_states.items(): + if not branch_contains_commit(branch, commit): + continue + if branch == config.main_branch: + candidates.append( + Candidate( + branch=branch, + source_tag=manual_tag, + mode="manual_exact", + publish_version=True, + publish_branch_tag=state.latest_tag == manual_tag, + reason=f"Manual rebuild for `{manual_tag}` on `{branch}`.", + ) + ) + continue + if state.latest_tag != manual_tag: + notes.append( + f"Skipped `{manual_tag}` on `{branch}` because non-main branches only publish their current branch tag and `{state.latest_tag}` is newer." + ) + continue + candidates.append( + Candidate( + branch=branch, + source_tag=manual_tag, + mode="manual_exact", + publish_version=False, + publish_branch_tag=True, + reason=f"Manual rebuild for the current branch image on `{branch}`.", + ) + ) + + if not candidates: + notes.append(f"No eligible images were found for manual tag `{manual_tag}`.") + return candidates, notes + + +def plan_manual_backfill(config: Config, branch_states: dict[str, BranchState]) -> tuple[list[Candidate], list[str]]: + notes: list[str] = [] + candidates: dict[tuple[str, str, str], Candidate] = {} + + for branch, state in branch_states.items(): + if not state.valid_tags: + notes.append(f"Branch `{branch}` has no releasable tags.") + continue + + if branch == config.main_branch: + for tag in state.valid_tags: + if docker_tag_exists(config.image_repo, tag): + continue + add_or_merge_candidate( + candidates, + Candidate( + branch=branch, + source_tag=tag, + mode="manual_backfill", + publish_version=True, + publish_branch_tag=False, + reason=f"Missing Docker Hub tag `{tag}`.", + ), + ) + + latest_tag = state.latest_tag + if latest_tag and not docker_tag_exists(config.image_repo, "latest"): + add_or_merge_candidate( + candidates, + Candidate( + branch=branch, + source_tag=latest_tag, + mode="manual_backfill", + publish_version=False, + publish_branch_tag=True, + reason="Missing Docker Hub tag `latest`.", + ), + ) + continue + + if not docker_tag_exists(config.image_repo, branch): + add_or_merge_candidate( + candidates, + Candidate( + branch=branch, + source_tag=state.latest_tag, + mode="manual_backfill", + publish_version=False, + publish_branch_tag=True, + reason=f"Missing Docker Hub tag `{branch}`.", + ), + ) + + if not candidates: + notes.append("No missing Docker Hub tags were found.") + return list(candidates.values()), notes + + +def plan_command() -> None: + config = load_config() + branch_states = collect_branch_states(config) + + if config.event_name == "workflow_dispatch": + if config.manual_tag: + candidates, notes = plan_manual_exact(config, branch_states) + else: + candidates, notes = plan_manual_backfill(config, branch_states) + elif config.event_name == "push": + candidates, notes = plan_push(config, branch_states) + else: + fail(f"Unsupported event: {config.event_name}") + + summary_lines = [candidate.reason for candidate in candidates] + summary_lines.extend(notes) + + matrix = {"include": [asdict(candidate) for candidate in candidates]} + write_output("has_work", "true" if candidates else "false") + write_output("matrix", json.dumps(matrix)) + write_summary(summary_lines) + + print(json.dumps(matrix, indent=2)) + for line in summary_lines: + print(f"- {line}") + + +def unique(items: list[str]) -> list[str]: + seen: set[str] = set() + output: list[str] = [] + for item in items: + if item in seen: + continue + seen.add(item) + output.append(item) + return output + + +def resolve_release_command() -> None: + config = load_config() + branch = os.environ["TARGET_BRANCH"].strip() + source_tag = os.environ["TARGET_TAG"].strip() + notes_dir = os.environ["RELEASE_NOTES_DIR"].strip() + + if branch != config.main_branch: + write_output("should_release", "false") + write_output("skip_reason", f"Branch `{branch}` does not publish GitHub releases.") + return + + branch_state = collect_branch_states(config, [branch])[branch] + if branch_state.latest_tag is None: + write_output("should_release", "false") + write_output("skip_reason", f"Branch `{branch}` has no releasable tags.") + return + + if parse_release_tag(config, source_tag) is None or not tag_exists(source_tag): + write_output("should_release", "false") + write_output("skip_reason", f"Tag `{source_tag}` is not a releasable tag.") + return + + commit = tag_commit(source_tag) + if not branch_contains_commit(branch, commit): + write_output("should_release", "false") + write_output("skip_reason", f"Tag `{source_tag}` is no longer reachable from `{branch}`.") + return + + if branch_state.latest_tag != source_tag: + write_output("should_release", "false") + write_output( + "skip_reason", + f"Tag `{source_tag}` is not the highest release tag on `{branch}`.", + ) + return + + notes_path = os.path.join(notes_dir, f"{source_tag}.md") + if not os.path.exists(notes_path): + fail( + f"Expected release notes file `{notes_path}` for GitHub release `{source_tag}`." + ) + + with open(notes_path, "r", encoding="utf-8") as handle: + body = handle.read().strip() + + write_output("should_release", "true") + write_output("release_tag", source_tag) + write_output("release_name", source_tag) + write_output("release_notes_path", notes_path) + write_output("release_body", body or "No release notes.") + print(source_tag) + + +def resolve_build_command() -> None: + config = load_config() + branch = os.environ["TARGET_BRANCH"].strip() + source_tag = os.environ["TARGET_TAG"].strip() + mode = os.environ["TARGET_MODE"].strip() + publish_version = os.environ["TARGET_PUBLISH_VERSION"].strip().lower() == "true" + publish_branch_tag = os.environ["TARGET_PUBLISH_BRANCH_TAG"].strip().lower() == "true" + + branch_state = collect_branch_states(config, [branch])[branch] + if branch_state.latest_tag is None: + write_output("should_build", "false") + write_output("skip_reason", f"Branch `{branch}` has no releasable tags.") + return + + if parse_release_tag(config, source_tag) is None or not tag_exists(source_tag): + write_output("should_build", "false") + write_output("skip_reason", f"Tag `{source_tag}` is no longer available.") + return + + commit = tag_commit(source_tag) + if not branch_contains_commit(branch, commit): + write_output("should_build", "false") + write_output("skip_reason", f"Tag `{source_tag}` is no longer reachable from `{branch}`.") + return + + mutable_tag = "latest" if branch == config.main_branch else branch + tags_to_push: list[str] = [] + + if mode == "push_latest_only": + if branch_state.latest_tag != source_tag: + write_output("should_build", "false") + write_output( + "skip_reason", + f"Tag `{source_tag}` is no longer the highest release tag on `{branch}`.", + ) + return + if publish_version: + tags_to_push.append(f"{config.image_repo}:{source_tag}") + if publish_branch_tag: + tags_to_push.append(f"{config.image_repo}:{mutable_tag}") + + elif mode == "manual_exact": + if publish_version: + tags_to_push.append(f"{config.image_repo}:{source_tag}") + if publish_branch_tag and branch_state.latest_tag == source_tag: + tags_to_push.append(f"{config.image_repo}:{mutable_tag}") + + elif mode == "manual_backfill": + if publish_version and not docker_tag_exists(config.image_repo, source_tag): + tags_to_push.append(f"{config.image_repo}:{source_tag}") + if publish_branch_tag: + if branch != config.main_branch and branch_state.latest_tag != source_tag: + write_output("should_build", "false") + write_output( + "skip_reason", + f"Tag `{source_tag}` is no longer the newest release tag on `{branch}`.", + ) + return + if branch == config.main_branch and branch_state.latest_tag != source_tag: + publish_branch_tag = False + if publish_branch_tag and not docker_tag_exists(config.image_repo, mutable_tag): + tags_to_push.append(f"{config.image_repo}:{mutable_tag}") + else: + fail(f"Unsupported resolve-build mode: {mode}") + + tags_to_push = unique(tags_to_push) + if not tags_to_push: + write_output("should_build", "false") + write_output("skip_reason", "All requested Docker tags already exist or are no longer eligible.") + return + + write_output("should_build", "true") + write_output("tags", "\n".join(tags_to_push)) + write_output("display_tags", ", ".join(tag.rsplit(":", 1)[1] for tag in tags_to_push)) + print("\n".join(tags_to_push)) + + +def main() -> None: + if len(sys.argv) != 2: + fail("Usage: docker_release_plan.py ") + + command = sys.argv[1] + if command == "plan": + plan_command() + return + if command == "resolve-build": + resolve_build_command() + return + if command == "resolve-release": + resolve_release_command() + return + fail(f"Unknown command: {command}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 000000000..b819cd46c --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,222 @@ +name: Build And Publish Docker Images + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Optional release tag to rebuild, for example v1.21" + required: false + type: string + +env: + # Non-main branches publish a Docker tag with the same name as the branch. + ALLOWED_BRANCHES: "testing main" + MAIN_BRANCH: "main" + RELEASE_TAG_REGEX: "^v([0-9]+)\\.([0-9]+)$" + MIN_RELEASE_MAJOR: "1" + MIN_RELEASE_MINOR: "0" + RELEASE_NOTES_DIR: "docs/release_notes" + DOCKERFILE_DIR: "docker/run" + DOCKERFILE_PATH: "docker/run/Dockerfile" + DOCKER_IMAGE_NAME: "agent-zero" + DOCKER_PLATFORMS: "linux/amd64,linux/arm64" + +permissions: + contents: read + +jobs: + plan: + if: github.repository == 'agent0ai/agent-zero' + runs-on: ubuntu-latest + outputs: + has_work: ${{ steps.plan.outputs.has_work }} + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - name: Validate Docker Hub secrets + env: + DOCKERHUB_ORG: ${{ secrets.DOCKERHUB_ORG }} + DOCKERHUB_OAT_TOKEN: ${{ secrets.DOCKERHUB_OAT_TOKEN }} + run: | + if [[ -z "$DOCKERHUB_ORG" || -z "$DOCKERHUB_OAT_TOKEN" ]]; then + echo "::error::Missing DOCKERHUB_ORG or DOCKERHUB_OAT_TOKEN secret." + exit 1 + fi + + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch remote branches and tags + run: git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_ORG }} + password: ${{ secrets.DOCKERHUB_OAT_TOKEN }} + + - name: Plan Docker publish targets + id: plan + env: + EVENT_NAME: ${{ github.event_name }} + SOURCE_TAG: ${{ github.ref_name }} + MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }} + DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }} + run: python3 .github/scripts/docker_release_plan.py plan + + build: + if: needs.plan.outputs.has_work == 'true' + needs: plan + runs-on: ubuntu-latest + permissions: + contents: write + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.plan.outputs.matrix) }} + concurrency: + group: docker-publish-${{ github.repository }}-${{ matrix.branch }} + cancel-in-progress: false + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch remote branches and tags + run: git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*' + + - name: Validate Docker Hub secrets + env: + DOCKERHUB_ORG: ${{ secrets.DOCKERHUB_ORG }} + DOCKERHUB_OAT_TOKEN: ${{ secrets.DOCKERHUB_OAT_TOKEN }} + run: | + if [[ -z "$DOCKERHUB_ORG" || -z "$DOCKERHUB_OAT_TOKEN" ]]; then + echo "::error::Missing DOCKERHUB_ORG or DOCKERHUB_OAT_TOKEN secret." + exit 1 + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_ORG }} + password: ${{ secrets.DOCKERHUB_OAT_TOKEN }} + + - name: Re-resolve Docker tags for this build + id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + SOURCE_TAG: ${{ github.ref_name }} + MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }} + DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }} + TARGET_BRANCH: ${{ matrix.branch }} + TARGET_TAG: ${{ matrix.source_tag }} + TARGET_MODE: ${{ matrix.mode }} + TARGET_PUBLISH_VERSION: ${{ matrix.publish_version }} + TARGET_PUBLISH_BRANCH_TAG: ${{ matrix.publish_branch_tag }} + run: python3 .github/scripts/docker_release_plan.py resolve-build + + - name: Skip when target is no longer eligible + if: steps.resolve.outputs.should_build != 'true' + run: echo "${{ steps.resolve.outputs.skip_reason }}" + + - name: Set cache date + if: steps.resolve.outputs.should_build == 'true' + id: cache_date + run: echo "value=$(date -u +%Y-%m-%d:%H:%M:%S)" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + if: steps.resolve.outputs.should_build == 'true' + uses: docker/build-push-action@v6 + with: + context: ${{ env.DOCKERFILE_DIR }} + file: ${{ env.DOCKERFILE_PATH }} + platforms: ${{ env.DOCKER_PLATFORMS }} + push: true + tags: ${{ steps.resolve.outputs.tags }} + build-args: | + BRANCH=${{ matrix.branch }} + CACHE_DATE=${{ steps.cache_date.outputs.value }} + + - name: Resolve GitHub release target + if: steps.resolve.outputs.should_build == 'true' + id: release_plan + env: + EVENT_NAME: ${{ github.event_name }} + SOURCE_TAG: ${{ github.ref_name }} + MANUAL_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || '' }} + DOCKER_IMAGE_REPO: ${{ format('{0}/{1}', secrets.DOCKERHUB_ORG, env.DOCKER_IMAGE_NAME) }} + TARGET_BRANCH: ${{ matrix.branch }} + TARGET_TAG: ${{ matrix.source_tag }} + RELEASE_NOTES_DIR: ${{ env.RELEASE_NOTES_DIR }} + run: python3 .github/scripts/docker_release_plan.py resolve-release + + - name: Skip GitHub release + if: steps.resolve.outputs.should_build == 'true' && steps.release_plan.outputs.should_release != 'true' + run: echo "${{ steps.release_plan.outputs.skip_reason }}" + + - name: Create or update GitHub release + if: steps.resolve.outputs.should_build == 'true' && steps.release_plan.outputs.should_release == 'true' + uses: actions/github-script@v7 + env: + RELEASE_TAG: ${{ steps.release_plan.outputs.release_tag }} + RELEASE_NAME: ${{ steps.release_plan.outputs.release_name }} + RELEASE_BODY: ${{ steps.release_plan.outputs.release_body }} + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const tag = process.env.RELEASE_TAG; + const name = process.env.RELEASE_NAME; + const body = process.env.RELEASE_BODY; + + try { + const existing = await github.rest.repos.getReleaseByTag({ + owner, + repo, + tag, + }); + + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: existing.data.id, + tag_name: tag, + name, + body, + draft: false, + prerelease: false, + make_latest: "true", + }); + + core.info(`Updated release ${tag}`); + } catch (error) { + if (error.status !== 404) { + throw error; + } + + await github.rest.repos.createRelease({ + owner, + repo, + tag_name: tag, + name, + body, + draft: false, + prerelease: false, + make_latest: "true", + }); + + core.info(`Created release ${tag}`); + } diff --git a/AGENTS.md b/AGENTS.md index 21b930bac..2c3412fa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Frontend Deep Dives: [Component System](docs/agents/AGENTS.components.md) | [Mod 6. [Safety and Permissions](#safety-and-permissions) 7. [Code Examples](#code-examples) 8. [Git Workflow](#git-workflow) -9. [API Documentation](#api-documentation) +9. [Release Notes](#release-notes) 10. [Troubleshooting](#troubleshooting) --- @@ -103,6 +103,7 @@ Key Files: - python/helpers/plugins.py: Plugin discovery and configuration logic. - webui/js/AlpineStore.js: Store factory for reactive frontend state. - python/helpers/api.py: Base class for all API endpoints. +- docs/release_notes/: Markdown files used by the release workflow to populate GitHub releases for the latest `main` tag. - 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. - docs/agents/AGENTS.components.md: Deep dive into the frontend component architecture. - docs/agents/AGENTS.modals.md: Guide to the stacked modal system. @@ -144,6 +145,15 @@ Key Files: - 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. - 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. +### Releases +- Docker publishing automation lives in `.github/workflows/docker-publish.yml`. +- Releasable tags follow `v{X}.{Y}` and only tags `>= v1.0` are considered by the workflow. +- The latest eligible tag on `main` also creates or updates a GitHub release after the Docker image push succeeds. +- Release notes live in `docs/release_notes/.md`. +- 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. +- Prioritize user-visible features, important fixes, infra or packaging changes, and breaking notes. Skip low-signal churn. +- If no notes are needed, an empty `docs/release_notes/.md` is valid and publishes `No release notes.` + ### Lifecycle Synchronization | Action | Backend Extension | Frontend Lifecycle | |---|---|---| @@ -209,6 +219,21 @@ class MyTool(Tool): --- +## Git Workflow + +- Docker publish automation lives in `.github/workflows/docker-publish.yml`. +- Release tags handled by automation must match `vX.Y` and be `>= v1.0`. +- Allowed release branches are configured at the top of the workflow. `main` publishes `` and `latest`; other allowed branches publish only the branch tag. +- 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`. + +--- + +## Release Notes + +- Store release notes in `docs/release_notes/` as `vX.Y.md`. +- Keep them concise and summarize changes since the previous release notes tag. +- The latest eligible `main` tag uses that file for the GitHub release body after Docker publish succeeds. + ## Troubleshooting ### Dependency Conflicts @@ -226,5 +251,5 @@ pip install -r requirements2.txt --- -*Last updated: 2026-02-22* +*Last updated: 2026-03-25* *Maintained by: Agent Zero Core Team* diff --git a/README.md b/README.md index f47f44b50..d5b230ad9 100644 --- a/README.md +++ b/README.md @@ -170,10 +170,13 @@ docker run -p 50001:80 agent0ai/agent-zero | [Architecture](./docs/developer/architecture.md) | System design and components | | [Contributing](./docs/guides/contribution.md) | How to contribute | | [Troubleshooting](./docs/guides/troubleshooting.md) | Common issues and their solutions | +| [Release Notes](./docs/release_notes/README.md) | Release note format used by the automated Docker and GitHub release workflow | ## 🎯 Changelog +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. + ### v0.9.8 - Skills, UI Redesign & Git projects [Release video](https://youtu.be/NV7s78yn6DY) diff --git a/docs/README.md b/docs/README.md index 847eaf33c..ef9ff20dc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ Welcome to the Agent Zero documentation hub. Whether you're getting started or d - **[Notifications](developer/notifications.md):** Notification system architecture and setup. - **[Contributing Skills](developer/contributing-skills.md):** Create and share agent skills. - **[Contributing Guide](guides/contribution.md):** Contribute to the Agent Zero project. +- **[Release Notes](release_notes/README.md):** File format and process used by the automated Docker and GitHub release workflow. ## Community & Support diff --git a/docs/release_notes/README.md b/docs/release_notes/README.md new file mode 100644 index 000000000..20107215a --- /dev/null +++ b/docs/release_notes/README.md @@ -0,0 +1,10 @@ +# Release Notes + +Create one file per release tag in this folder using the exact name `vX.Y.md`, for example `v2.33.md`. + +Rules: +- The automated Docker publish workflow reads `docs/release_notes/.md` when the current latest `main` release tag is built successfully. +- Keep the notes concise and release-ready. Summarize the meaningful changes since the previous release notes tag in this folder. +- Prefer user-facing features, major fixes, notable infrastructure or packaging changes, and breaking or migration notes. Skip low-signal internal churn. +- Use normal Markdown. A short heading plus a flat bullet list is enough. +- If you intentionally want a release with no notes, leave the file empty and the workflow will publish `No release notes.` diff --git a/docs/setup/dev-setup.md b/docs/setup/dev-setup.md index f6099700d..453a21c60 100644 --- a/docs/setup/dev-setup.md +++ b/docs/setup/dev-setup.md @@ -174,3 +174,4 @@ These environment variables automatically override the hardcoded defaults in `ge - 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) .` - 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. - See `docker/run/build.txt` for more build command examples. +- 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.