diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 77cb4bb..8433cc6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -295,13 +295,39 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Compute image tags + id: prep + run: | + image="$(printf '%s' "${REGISTRY}/${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')" + sha_tag="${image}:sha-${GITHUB_SHA}" + raw_version="$(sed -n 's/^ARG UPSTREAM_VERSION=//p' Dockerfile | head -n1)" + upstream_version="${raw_version%%@*}" + aio_track="aio-v1" + + { + echo "upstream_version=${upstream_version}" + echo "tags<> "${GITHUB_OUTPUT}" + - name: Build and push multi-arch image uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . platforms: ${{ env.PUBLISH_PLATFORMS }} push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + tags: ${{ steps.prep.outputs.tags }} + labels: | + org.opencontainers.image.source=https://github.com/JSONbored/khoj-aio + org.opencontainers.image.title=khoj-aio + org.opencontainers.image.version=${{ steps.prep.outputs.upstream_version || '' }} + io.jsonbored.upstream.name=Khoj load: false sync-awesome-unraid: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..18b02a4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release / Khoj-AIO + +on: + workflow_dispatch: + pull_request_target: + types: [closed] + +jobs: + prepare-release: + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - name: Install git-cliff + env: + GIT_CLIFF_VERSION: 2.12.0 + run: | + archive="git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + curl -fsSL -o "/tmp/${archive}" "https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/${archive}" + tar -xzf "/tmp/${archive}" -C /tmp + install "/tmp/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" /usr/local/bin/git-cliff + - name: Compute release version + id: version + run: echo "release_version=$(python3 scripts/release.py next-version)" >> "${GITHUB_OUTPUT}" + - name: Generate changelog + env: + GITHUB_REPO: ${{ github.repository }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} + run: git-cliff --config cliff.toml --tag "${RELEASE_VERSION}" --output CHANGELOG.md + - name: Create release PR + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + with: + commit-message: "chore(release): ${{ steps.version.outputs.release_version }}" + title: "chore(release): ${{ steps.version.outputs.release_version }}" + body: | + This PR prepares `${{ steps.version.outputs.release_version }}`. + branch: "release/${{ steps.version.outputs.release_version }}" + delete-branch: true + + publish-release-on-merge: + if: "${{ github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && startsWith(github.event.pull_request.title, 'chore(release): ') }}" + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout merge commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + - name: Determine release version + id: version + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + release_version="${PR_TITLE#chore(release): }" + echo "release_version=${release_version}" >> "${GITHUB_OUTPUT}" + test "$(python3 scripts/release.py latest-changelog-version)" = "${release_version}" + - name: Extract release notes + id: notes + env: + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} + run: | + { + echo "release_notes<> "${GITHUB_OUTPUT}" + - name: Create Git tag if missing + env: + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + if git rev-parse "${RELEASE_VERSION}" >/dev/null 2>&1; then exit 0; fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag "${RELEASE_VERSION}" "${MERGE_SHA}" + git push origin "${RELEASE_VERSION}" + - name: Publish GitHub release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} + RELEASE_NOTES: ${{ steps.notes.outputs.release_notes }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + if gh release view "${RELEASE_VERSION}" >/dev/null 2>&1; then exit 0; fi + notes_file="$(mktemp)" + printf '%s\n' "${RELEASE_NOTES}" > "${notes_file}" + gh release create "${RELEASE_VERSION}" --title "${RELEASE_VERSION}" --notes-file "${notes_file}" --target "${MERGE_SHA}" diff --git a/AGENTS.md b/AGENTS.md index a9cb48c..63aef43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,33 +1,40 @@ -# khoj-aio Agent Notes +# AGENTS.md -`khoj-aio` wraps Khoj as a single-container Unraid deployment with an internal PostgreSQL database. +This repository is part of a broader portfolio of Unraid-first AIO projects. -## Runtime Shape +## Repository intent -- Upstream app: `ghcr.io/khoj-ai/khoj` -- Internal PostgreSQL with `pgvector` -- Persistent config and model/cache storage under appdata -- Optional first-run secret/admin credential generation +- This repo packages an opinionated, beginner-friendly Unraid AIO deployment. +- Default behavior should optimize for a reliable first boot on Unraid. +- Advanced users should retain escape hatches where supported. -## Important Behavior +## Engineering expectations -- This repo tracks upstream prereleases because current upstream packaging reality is prerelease-oriented. -- `upstream.toml` intentionally uses `stable_only = false`. -- Default mode should remain self-contained and beginner-friendly. -- Advanced users can still override providers, proxies, sandboxes, or external PostgreSQL. +- Prefer consistency with `unraid-aio-template` over one-off repo behavior. +- Keep CI and release behavior aligned with the rest of the AIO fleet. +- Respect protected branches and use PR-based automation for external sync flows. +- Favor operational clarity over cleverness. -## CI And Publish Policy +## Release model -- Validation and smoke tests should run on PRs and branch pushes. -- Publish should happen only from the default branch. -- GHCR image naming must stay lowercase. +- Container packages publish automatically from `main`. +- Formal changelog updates and GitHub Releases are release-driven. +- Releases use `upstream version + aio revision`, for example `2.0.0-beta.28-aio.1`. +- Keep changelog-friendly Conventional Commit titles and PR titles. -## What To Preserve +## Unraid expectations -- Keep the XML easy for first-time Unraid users. -- Generated secrets should be persisted so restarts are stable. -- Smoke coverage should include first boot, restart, and persistence. +- Unraid-facing XML/icon assets should stay aligned with `awesome-unraid`. +- User-facing metadata should remain accurate: + - `Project` + - `Support` + - `TemplateURL` + - `Icon` + - `Overview` + - `Category` -## Known Good Pattern +## Documentation expectations -- After the first boot, a restart remains a useful validation step because Khoj settings finalize cleanly on restart. +- Be explicit about operational tradeoffs. +- Do not imply the AIO model removes inherent complexity from the upstream software. +- Keep beginner defaults simple, but document power-user override paths where they exist. diff --git a/README.md b/README.md index b22131a..f7a219b 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,10 @@ Published image tags are intended to include: - wrapper tags like `2.0.0-beta.28-aio-v1` - `sha-` tags for exact reproducibility +Formal GitHub releases should follow the same upstream-version-plus-AIO-revision pattern, such as `2.0.0-beta.28-aio.1`. + +See [docs/releases.md](/Users/shadowbook/Documents/khoj-aio/docs/releases.md) for the release workflow details. + ## License and Acknowledgements - The upstream application is maintained by the [khoj-ai/khoj](https://github.com/khoj-ai/khoj) team. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..8fd02c0 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,46 @@ +[changelog] +header = """ +# Changelog + +All notable changes to this project will be documented in this file. +""" +body = """ +{% if version %}## {{ version }} - {{ timestamp | date(format="%Y-%m-%d") }}{% else %}## Unreleased{% endif %} +{% for group, commits in commits | group_by(attribute="group") -%} +### {{ group }} +{% for commit in commits -%} +- {{ commit.message | split(pat="\n") | first | trim | upper_first }} +{% endfor %} +{% if not loop.last %}\n{% endif -%} +{% endfor -%} +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = false +require_conventional = false +split_commits = false +protect_breaking_commits = true +tag_pattern = '^v?[0-9].*-aio\\.[0-9]+$' +sort_commits = "oldest" +commit_preprocessors = [ + { pattern = " \\(#\\d+\\)$", replace = "" }, +] +commit_parsers = [ + { message = "^Merge pull request", skip = true }, + { message = "^chore\\(release\\):", skip = true }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactors" }, + { message = "^docs?", group = "Documentation" }, + { message = "^ci", group = "CI" }, + { message = "^test", group = "Tests" }, + { message = "^build", group = "Build" }, + { message = "^chore\\(deps", group = "Dependency Updates" }, + { message = "^chore", group = "Maintenance" }, + { message = "^revert", group = "Reverts" }, + { message = "^[A-Z].*", group = "Other Changes" }, +] diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..03b85e4 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,19 @@ +# Releases + +`khoj-aio` uses upstream-version-plus-AIO-revision releases such as `2.0.0-beta.28-aio.1`. + +## Published image tags + +Every `main` build publishes: + +- `latest` +- the exact pinned upstream version +- an explicit packaging line tag like `2.0.0-beta.28-aio-v1` +- `sha-` + +## Release flow + +1. Trigger **Release / Khoj-AIO** from `main`. +2. The workflow computes the next `upstream-aio.N` version and opens a release PR. +3. Merge that PR into `main`. +4. After merge, the workflow creates the Git tag and GitHub Release automatically. diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..71c8eeb --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import pathlib +import re +import subprocess + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DEFAULT_CHANGELOG = ROOT / "CHANGELOG.md" +DEFAULT_DOCKERFILE = ROOT / "Dockerfile" +DEFAULT_UPSTREAM = ROOT / "upstream.toml" + + +def load_upstream_version_key(path: pathlib.Path) -> str: + in_upstream = False + pattern = re.compile(r'^version_key\s*=\s*"([^"]+)"\s*$') + for raw_line in path.read_text().splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + in_upstream = line == "[upstream]" + continue + if not in_upstream: + continue + match = pattern.match(line) + if match: + return match.group(1) + return "UPSTREAM_VERSION" + + +def read_upstream_version(dockerfile: pathlib.Path, upstream: pathlib.Path) -> str: + version_key = load_upstream_version_key(upstream) + pattern = re.compile(rf"^ARG {re.escape(version_key)}=(.+)$") + for line in dockerfile.read_text().splitlines(): + match = pattern.match(line.strip()) + if match: + return match.group(1).split("@", 1)[0] + raise SystemExit(f"Unable to find ARG {version_key} in {dockerfile}") + + +def git_tags() -> list[str]: + output = subprocess.check_output(["git", "tag", "--list"], cwd=ROOT, text=True) + return [line.strip() for line in output.splitlines() if line.strip()] + + +def next_release_version(dockerfile: pathlib.Path, upstream: pathlib.Path) -> str: + upstream_version = read_upstream_version(dockerfile, upstream) + pattern = re.compile(rf"^{re.escape(upstream_version)}-aio\.(\d+)$") + revisions = [] + for tag in git_tags(): + match = pattern.match(tag) + if match: + revisions.append(int(match.group(1))) + next_revision = max(revisions, default=0) + 1 + return f"{upstream_version}-aio.{next_revision}" + + +def latest_changelog_version(changelog: pathlib.Path) -> str: + pattern = re.compile(r"^##\s+([^\s]+)") + for line in changelog.read_text().splitlines(): + match = pattern.match(line.strip()) + if match and match.group(1) != "Unreleased": + return match.group(1) + raise SystemExit(f"Unable to find a released version heading in {changelog}") + + +def extract_release_notes(version: str, changelog: pathlib.Path) -> str: + heading = re.compile(rf"^##\s+{re.escape(version)}(?:\s+-\s+.+)?$") + next_heading = re.compile(r"^##\s+") + lines = changelog.read_text().splitlines() + start = None + for index, line in enumerate(lines): + if heading.match(line.strip()): + start = index + 1 + break + if start is None: + raise SystemExit(f"Unable to find release section for {version} in {changelog}") + end = len(lines) + for index in range(start, len(lines)): + if next_heading.match(lines[index].strip()): + end = index + break + notes = "\n".join(lines[start:end]).strip() + if not notes: + raise SystemExit(f"Release section for {version} in {changelog} is empty") + return notes + + +def main() -> None: + parser = argparse.ArgumentParser(description="Release helpers for AIO repos.") + subparsers = parser.add_subparsers(dest="command", required=True) + upstream_parser = subparsers.add_parser("upstream-version") + upstream_parser.add_argument("--dockerfile", type=pathlib.Path, default=DEFAULT_DOCKERFILE) + upstream_parser.add_argument("--upstream-config", type=pathlib.Path, default=DEFAULT_UPSTREAM) + next_parser = subparsers.add_parser("next-version") + next_parser.add_argument("--dockerfile", type=pathlib.Path, default=DEFAULT_DOCKERFILE) + next_parser.add_argument("--upstream-config", type=pathlib.Path, default=DEFAULT_UPSTREAM) + latest_parser = subparsers.add_parser("latest-changelog-version") + latest_parser.add_argument("--changelog", type=pathlib.Path, default=DEFAULT_CHANGELOG) + notes_parser = subparsers.add_parser("extract-release-notes") + notes_parser.add_argument("version") + notes_parser.add_argument("--changelog", type=pathlib.Path, default=DEFAULT_CHANGELOG) + args = parser.parse_args() + if args.command == "upstream-version": + print(read_upstream_version(args.dockerfile, args.upstream_config)) + elif args.command == "next-version": + print(next_release_version(args.dockerfile, args.upstream_config)) + elif args.command == "latest-changelog-version": + print(latest_changelog_version(args.changelog)) + else: + print(extract_release_notes(args.version, args.changelog)) + + +if __name__ == "__main__": + main()