From 28269954e49f60fdf9182805aeb821fd8f4805f9 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:56:24 -0400 Subject: [PATCH] fix(update): panels update from published releases only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel's Update followed `agenthits-dev`, which moves with every merge into AgentHits-Dev, so a click on Update installed whatever was merged last, release or not. A release now also moves `agenthits-stable`: after the GitHub release is published, the new `agenthits-stable-tag.yml` workflow copies the release's manifest to that tag (`imagetools create --prefer-index=false`, same digest, no rebuild) and checks the copy byte for byte. A manual run with an older release moves the channel back to it. GHCR retention never deletes the tag. The panel's update check and Update now follow `agenthits-stable` by default. A panel running a build made after the newest release (agenthits-vX.Y.Z-N-g…) is not offered that older release, because its database may already carry the build's migrations; a newer release, or a rebuilt image of the same release, is still offered. DOKPLOY_AGENTHITS_UPDATE_TAG=agenthits-dev keeps a test panel on every build. A missing tag only makes the check report no update. install-agenthits.sh and update.sh still default to `agenthits-dev`. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/agenthits-ghcr-retention.sh | 4 +- .../workflows/agenthits-ghcr-retention.yml | 5 +- .github/workflows/agenthits-release.yml | 11 ++ .github/workflows/agenthits-stable-tag.yml | 80 +++++++++++ CLAUDE.md | 2 +- README.md | 7 +- .../settings/dokploy-image-cleanup.test.ts | 8 +- .../__test__/settings/fork-version.test.ts | 131 ++++++++++++++++-- docs/agenthits-fork.md | 8 +- docs/agenthits-install.md | 14 +- packages/server/src/services/settings.ts | 38 ++++- 11 files changed, 278 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/agenthits-stable-tag.yml diff --git a/.github/scripts/agenthits-ghcr-retention.sh b/.github/scripts/agenthits-ghcr-retention.sh index 81865c339a..29bae1ab35 100755 --- a/.github/scripts/agenthits-ghcr-retention.sh +++ b/.github/scripts/agenthits-ghcr-retention.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Deletes old GHCR versions of the AgentHits image. Kept: every version tagged -# agenthits-vX.Y.Z, agenthits-dev or buildcache, the newest KEEP_RECENT other +# agenthits-vX.Y.Z, agenthits-dev, agenthits-stable or buildcache, the newest KEEP_RECENT other # versions, and every manifest a kept image index points at (GHCR lists the # platform manifests of an index as separate untagged versions, and deleting # them breaks the tagged image). @@ -19,7 +19,7 @@ versions="$(gh api --paginate "${api}?per_page=100" | jq -s 'add // []')" plan="$(jq --argjson keep "$keep_recent" ' def protected: (.metadata.container.tags // []) - | any(. == "agenthits-dev" or . == "buildcache" or test("^agenthits-v[0-9]+\\.[0-9]+\\.[0-9]+$")); + | any(. == "agenthits-dev" or . == "agenthits-stable" or . == "buildcache" or test("^agenthits-v[0-9]+\\.[0-9]+\\.[0-9]+$")); sort_by(.created_at) | reverse | map(select(protected | not)) as $rest | {keep: (map(select(protected)) + $rest[:$keep]), delete: $rest[$keep:]} diff --git a/.github/workflows/agenthits-ghcr-retention.yml b/.github/workflows/agenthits-ghcr-retention.yml index f4361743da..62b9fb21ad 100644 --- a/.github/workflows/agenthits-ghcr-retention.yml +++ b/.github/workflows/agenthits-ghcr-retention.yml @@ -1,7 +1,8 @@ name: AgentHits GHCR Retention # Scheduled runs delete; manual runs only list what would go unless dry-run is -# switched off. Release tags, agenthits-dev and buildcache are never deleted. +# switched off. Release tags, agenthits-dev, agenthits-stable and buildcache are +# never deleted. on: schedule: - cron: "17 3 * * 1" @@ -12,7 +13,7 @@ on: type: boolean default: true keep-recent: - description: Newest versions to keep besides release, agenthits-dev and buildcache tags + description: Newest versions to keep besides release, agenthits-dev, agenthits-stable and buildcache tags type: number default: 10 diff --git a/.github/workflows/agenthits-release.yml b/.github/workflows/agenthits-release.yml index 10e1fbd9ef..420019e1b8 100644 --- a/.github/workflows/agenthits-release.yml +++ b/.github/workflows/agenthits-release.yml @@ -83,3 +83,14 @@ jobs: --target "$GITHUB_SHA" \ --title "$TITLE" \ --notes-file release-notes.md + + stable: + needs: [version, release] + if: needs.version.outputs.tag != '' + uses: ./.github/workflows/agenthits-stable-tag.yml + permissions: + contents: read + packages: write + with: + release-tag: ${{ needs.version.outputs.tag }} + secrets: inherit diff --git a/.github/workflows/agenthits-stable-tag.yml b/.github/workflows/agenthits-stable-tag.yml new file mode 100644 index 0000000000..53763305f8 --- /dev/null +++ b/.github/workflows/agenthits-stable-tag.yml @@ -0,0 +1,80 @@ +name: AgentHits Stable Tag + +# Panels update from agenthits-stable, so it must name a published release and +# nothing else. The tag is a carbon copy of the release's manifest: the digest +# stays the same and nothing is rebuilt. A manual run with an older release +# moves the update channel back to it. +on: + workflow_call: + inputs: + release-tag: + description: Published agenthits-vX.Y.Z release to point agenthits-stable at + type: string + required: true + workflow_dispatch: + inputs: + release-tag: + description: "Published agenthits-vX.Y.Z release to point agenthits-stable at (empty: the latest release)" + type: string + default: "" + +permissions: + contents: read + packages: write + +concurrency: + group: agenthits-stable-tag + cancel-in-progress: false + +env: + IMAGE: ghcr.io/agenthits/dokploy + STABLE_TAG: agenthits-stable + +jobs: + move-tag: + runs-on: ubuntu-24.04 + steps: + - name: Resolve the release + id: release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release-tag }} + run: | + if [ -z "$RELEASE_TAG" ]; then + RELEASE_TAG="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)" + fi + if ! printf '%s' "$RELEASE_TAG" | grep -Eq '^agenthits-v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::release-tag must look like agenthits-vX.Y.Z, got '${RELEASE_TAG}'" + exit 1 + fi + if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json tagName >/dev/null; then + echo "::error::${RELEASE_TAG} is not a published GitHub release" + exit 1 + fi + echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + cleanup: false + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ secrets.GHCR_PUSH_TOKEN && 'agentHits' || github.actor }} + password: ${{ secrets.GHCR_PUSH_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Point agenthits-stable at the release + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + docker buildx imagetools create --prefer-index=false \ + --tag "${IMAGE}:${STABLE_TAG}" "${IMAGE}:${RELEASE_TAG}" + docker buildx imagetools inspect --raw "${IMAGE}:${RELEASE_TAG}" > release.json + docker buildx imagetools inspect --raw "${IMAGE}:${STABLE_TAG}" > stable.json + if ! cmp -s release.json stable.json; then + echo "::error::${STABLE_TAG} does not carry the manifest of ${RELEASE_TAG}" + exit 1 + fi + echo "${STABLE_TAG} now points at ${RELEASE_TAG} (sha256:$(sha256sum < release.json | cut -d' ' -f1))" diff --git a/CLAUDE.md b/CLAUDE.md index 99cad9b28e..d4fb07b004 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Every pull request targets `AgentHits-Dev`, including a PR that builds on anothe ## Releases -One GitHub release `agenthits-vX.Y.Z` covers a batch of work, not a single PR (owner's decision 2026-10-27). Every push to `AgentHits-Dev` builds the image through `.github/workflows/agenthits-release.yml` (`agenthits-dev`, `agenthits-dev-`), and panels show such a build as `agenthits-vX.Y.Z-N-g`: the previous release plus N commits. A push cuts the release once 20 PRs (`RELEASE_EVERY`, first-parent commits) have been merged since the previous release; to release a finished batch sooner, run `gh workflow run agenthits-release.yml --repo TaskFlow-Systems/dokploy --ref AgentHits-Dev`. Several PRs that belong together can also be merged as one combined PR. The `agenthits-` prefix keeps fork tags apart from upstream Dokploy `v0.x` tags. The version goes up by patch, by minor when a PR title in the batch starts with `feat`, and by major only through a manual run with `bump: major`. The release notes are the title and body of every PR merged since the previous release, so write each PR description for someone reading the release. The image of a release is `ghcr.io/agenthits/dokploy:agenthits-vX.Y.Z` and panels show that tag as the fork version; `agenthits-dev` always points at the newest build. `.github/workflows/agenthits-ghcr-retention.yml` deletes old GHCR versions weekly but never a release tag, `agenthits-dev` or `buildcache`. +One GitHub release `agenthits-vX.Y.Z` covers a batch of work, not a single PR (owner's decision 2026-10-27). Every push to `AgentHits-Dev` builds the image through `.github/workflows/agenthits-release.yml` (`agenthits-dev`, `agenthits-dev-`), and panels show such a build as `agenthits-vX.Y.Z-N-g`: the previous release plus N commits. A push cuts the release once 20 PRs (`RELEASE_EVERY`, first-parent commits) have been merged since the previous release; to release a finished batch sooner, run `gh workflow run agenthits-release.yml --repo TaskFlow-Systems/dokploy --ref AgentHits-Dev`. Several PRs that belong together can also be merged as one combined PR. The `agenthits-` prefix keeps fork tags apart from upstream Dokploy `v0.x` tags. The version goes up by patch, by minor when a PR title in the batch starts with `feat`, and by major only through a manual run with `bump: major`. The release notes are the title and body of every PR merged since the previous release, so write each PR description for someone reading the release. The image of a release is `ghcr.io/agenthits/dokploy:agenthits-vX.Y.Z` and panels show that tag as the fork version; `agenthits-dev` always points at the newest build. After the GitHub release is published, `.github/workflows/agenthits-stable-tag.yml` points `agenthits-stable` at the release's manifest (same digest, no rebuild); the panel's Update follows `agenthits-stable` and never offers a release older than the running build. A manual run of that workflow with an older release moves the channel back; `DOKPLOY_AGENTHITS_UPDATE_TAG=agenthits-dev` on a panel's service makes it follow every build. `install-agenthits.sh` and `update.sh` still default to `agenthits-dev`. `.github/workflows/agenthits-ghcr-retention.yml` deletes old GHCR versions weekly but never a release tag, `agenthits-dev`, `agenthits-stable` or `buildcache`. ## Dependencies diff --git a/README.md b/README.md index 3db816e6c2..b58e7b58a3 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,8 @@ docker service inspect dokploy --format '{{.Spec.TaskTemplate.ContainerSpec.Imag ``` Dashboard update button в AgentHits fork проверяет и обновляет image из -`ghcr.io/agenthits/dokploy:agenthits-dev`. +`ghcr.io/agenthits/dokploy:agenthits-stable`: это последний выпущенный релиз +`agenthits-vX.Y.Z`. Сборку новее этого релиза кнопка не откатывает назад. Подробная инструкция: [docs/agenthits-install.md](docs/agenthits-install.md). @@ -241,7 +242,9 @@ docker service inspect dokploy --format '{{.Spec.TaskTemplate.ContainerSpec.Imag ``` The dashboard update button in the AgentHits fork checks and updates the image -from `ghcr.io/agenthits/dokploy:agenthits-dev`. +from `ghcr.io/agenthits/dokploy:agenthits-stable`, the latest published +`agenthits-vX.Y.Z` release. It never moves a build newer than that release back +to it. Full guide: [docs/agenthits-install.md](docs/agenthits-install.md). diff --git a/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts b/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts index 9529f0e4a3..eb16e4a78f 100644 --- a/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts +++ b/apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts @@ -338,7 +338,7 @@ describe("Dokploy update commands", () => { it("pulls the AgentHits image while the old container still runs", () => { expectPullBeforeUpdate( getAgentHitsUpdateCommand("v0.30.6"), - "docker pull ghcr.io/agenthits/dokploy\\:agenthits-dev", + "docker pull ghcr.io/agenthits/dokploy\\:agenthits-stable", ); }); @@ -470,7 +470,7 @@ esac }); describe("Dokploy update command", () => { - const AGENTHITS_IMAGE = "ghcr.io/agenthits/dokploy:agenthits-dev"; + const AGENTHITS_IMAGE = "ghcr.io/agenthits/dokploy:agenthits-stable"; const PINNED = `sha256:${"c".repeat(64)}`; const FORK_VERSION = "agenthits-v1.0.9-3-g0123456789ab"; @@ -557,7 +557,7 @@ esac ]); expect(stdout).toContain(UPDATE_IMAGE_PULLED_MARKER); expect(update).toBe( - `service update --force --no-resolve-image --image ${AGENTHITS_IMAGE}@${PINNED} --env-add RELEASE_TAG=agenthits-dev --env-add DOKPLOY_OFFICIAL_VERSION=v0.30.7 --env-add DOKPLOY_FORK_VERSION=${FORK_VERSION} --env-add DOKPLOY_KEEP_OLD_IMAGES=2 --env-add DOKPLOY_PREVIOUS_IMAGE=${PREVIOUS} --update-order stop-first --update-failure-action rollback dokploy`, + `service update --force --no-resolve-image --image ${AGENTHITS_IMAGE}@${PINNED} --env-add RELEASE_TAG=agenthits-stable --env-add DOKPLOY_OFFICIAL_VERSION=v0.30.7 --env-add DOKPLOY_FORK_VERSION=${FORK_VERSION} --env-add DOKPLOY_KEEP_OLD_IMAGES=2 --env-add DOKPLOY_PREVIOUS_IMAGE=${PREVIOUS} --update-order stop-first --update-failure-action rollback dokploy`, ); expect(calls.at(-1)).toBe(update); }, @@ -631,7 +631,7 @@ esac if (url.includes("/token")) { return Response.json({ token: "token" }); } - if (url.endsWith("/manifests/agenthits-dev")) { + if (url.endsWith("/manifests/agenthits-stable")) { return Response.json( { config: { digest: "sha256:config" } }, { headers: { "docker-content-digest": latestDigest } }, diff --git a/apps/dokploy/__test__/settings/fork-version.test.ts b/apps/dokploy/__test__/settings/fork-version.test.ts index e109153510..371a89dee5 100644 --- a/apps/dokploy/__test__/settings/fork-version.test.ts +++ b/apps/dokploy/__test__/settings/fork-version.test.ts @@ -12,11 +12,13 @@ vi.mock("@dokploy/server/utils/process/execAsync", () => ({ const { getAgentHitsUpdateCommand, getAgentHitsUpdateData, + getAgentHitsUpdateImage, getDokployVersionData, getForkDokployVersion, getOfficialDokployVersion, getUpdateData, isAgentHitsUpdateChannel, + isOlderAgentHitsVersion, } = await import("@dokploy/server/services/settings"); const originalEnv = { ...process.env }; @@ -120,7 +122,7 @@ describe("AgentHits fork version metadata", () => { if (url.includes("/token")) { return createJsonResponse({ token: "token" }); } - if (url.endsWith("/manifests/agenthits-dev")) { + if (url.endsWith("/manifests/agenthits-stable")) { return createJsonResponse( { manifests: [ @@ -157,7 +159,7 @@ describe("AgentHits fork version metadata", () => { latestVersion: "off_v0.29.8/Fork_159+next", updateAvailable: true, updateSource: "agenthits", - latestImage: "ghcr.io/agenthits/dokploy:agenthits-dev", + latestImage: "ghcr.io/agenthits/dokploy:agenthits-stable", latestOfficialVersion: "v0.29.8", currentDigest: "sha256:current", latestDigest: "sha256:latest", @@ -180,7 +182,7 @@ describe("AgentHits fork version metadata", () => { if (url.includes("/token")) { return createJsonResponse({ token: "token" }); } - if (url.endsWith("/manifests/agenthits-dev")) { + if (url.endsWith("/manifests/agenthits-stable")) { return createJsonResponse( { config: { digest: "sha256:config" } }, { "docker-content-digest": "sha256:latest" }, @@ -225,7 +227,7 @@ describe("AgentHits fork version metadata", () => { if (url.includes("/token")) { return createJsonResponse({ token: "token" }); } - if (url.endsWith("/manifests/agenthits-dev")) { + if (url.endsWith("/manifests/agenthits-stable")) { return createJsonResponse( { manifests: [ @@ -279,7 +281,7 @@ describe("AgentHits fork version metadata", () => { if (url.includes("/token")) { return createJsonResponse({ token: "token" }); } - if (url.endsWith("/manifests/agenthits-dev")) { + if (url.endsWith("/manifests/agenthits-stable")) { return createJsonResponse( { manifests: [ @@ -340,7 +342,7 @@ describe("AgentHits fork version metadata", () => { if (url.includes("/token")) { return createJsonResponse({ token: "token" }); } - if (url.endsWith("/manifests/agenthits-dev")) { + if (url.endsWith("/manifests/agenthits-stable")) { return createJsonResponse( { manifests: [ @@ -444,7 +446,7 @@ describe("AgentHits fork version metadata", () => { if (url.includes("/token")) { return createJsonResponse({ token: "token" }); } - if (url.endsWith("/manifests/agenthits-dev")) { + if (url.endsWith("/manifests/agenthits-stable")) { return createJsonResponse( { manifests: [ @@ -490,13 +492,124 @@ describe("AgentHits fork version metadata", () => { ); expect(command).toContain( - "docker pull ghcr.io/agenthits/dokploy\\:agenthits-dev", + "docker pull ghcr.io/agenthits/dokploy\\:agenthits-stable", ); expect(command).toContain('--image "$pinned_image"'); - expect(command).toContain("--env-add RELEASE_TAG\\=agenthits-dev"); + expect(command).toContain("--env-add RELEASE_TAG\\=agenthits-stable"); expect(command).toContain("--env-add DOKPLOY_OFFICIAL_VERSION\\=v0.30.0"); expect(command).toContain( "--env-add DOKPLOY_FORK_VERSION\\=off_v0.29.8/Fork_159+next", ); }); }); + +describe("AgentHits release channel", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.DOKPLOY_AGENTHITS_UPDATE_IMAGE; + delete process.env.DOKPLOY_AGENTHITS_UPDATE_TAG; + delete process.env.DOKPLOY_OFFICIAL_VERSION; + process.env.RELEASE_TAG = "agenthits-stable"; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + resetVersionEnv(); + }); + + const stubLatestRelease = async (forkVersion: string) => { + const { execAsync } = await import( + "@dokploy/server/utils/process/execAsync" + ); + vi.mocked(execAsync).mockResolvedValue({ + stdout: "ghcr.io/agenthits/dokploy:agenthits-dev@sha256:running\n", + stderr: "", + }); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/token")) { + return createJsonResponse({ token: "token" }); + } + if (url.endsWith("/manifests/agenthits-stable")) { + return createJsonResponse( + { config: { digest: "sha256:config" } }, + { "docker-content-digest": "sha256:release" }, + ); + } + if (url.endsWith("/blobs/sha256:config")) { + return createJsonResponse({ + config: { + Env: [ + "DOKPLOY_OFFICIAL_VERSION=v0.30.6", + `DOKPLOY_FORK_VERSION=${forkVersion}`, + ], + }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + }; + + it("updates from the newest published release by default", () => { + expect(getAgentHitsUpdateImage()).toBe( + "ghcr.io/agenthits/dokploy:agenthits-stable", + ); + process.env.DOKPLOY_AGENTHITS_UPDATE_TAG = "agenthits-dev"; + expect(getAgentHitsUpdateImage()).toBe( + "ghcr.io/agenthits/dokploy:agenthits-dev", + ); + }); + + it.each([ + ["agenthits-v1.1.0", "agenthits-v1.1.0-1-gba742e125f30", true], + ["agenthits-v1.0.9", "agenthits-v1.1.0", true], + ["agenthits-v1.9.9", "agenthits-v1.10.0", true], + ["agenthits-v1.1.1", "agenthits-v1.1.0-3-gabcdef012345", false], + ["agenthits-v1.1.0", "agenthits-v1.1.0", false], + ["agenthits-v2.0.0", "agenthits-v1.10.4-7-gabcdef012345", false], + ["agenthits-v1.1.0", "off_v0.29.8/Fork_159+next", false], + ["agenthits-v1.1.0", "agenthits-dev-abcdef012345", false], + ])("treats %s as older than %s: %s", (candidate, current, older) => { + expect(isOlderAgentHitsVersion(candidate, current)).toBe(older); + }); + + it("does not offer a build made after the release that older release", async () => { + process.env.DOKPLOY_FORK_VERSION = "agenthits-v1.1.0-1-gba742e125f30"; + const fetchMock = await stubLatestRelease("agenthits-v1.1.0"); + + const data = await getAgentHitsUpdateData("v0.30.6"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://ghcr.io/v2/agenthits/dokploy/manifests/agenthits-stable", + expect.anything(), + ); + expect(data).toMatchObject({ + latestVersion: "agenthits-v1.1.0", + updateAvailable: false, + currentDigest: "sha256:running", + latestDigest: "sha256:release", + }); + }); + + it("offers the next release to a build made after the previous one", async () => { + process.env.DOKPLOY_FORK_VERSION = "agenthits-v1.1.0-1-gba742e125f30"; + await stubLatestRelease("agenthits-v1.1.1"); + + expect(await getAgentHitsUpdateData("v0.30.6")).toMatchObject({ + latestVersion: "agenthits-v1.1.1", + updateAvailable: true, + }); + }); + + it("offers a rebuilt image of the same release", async () => { + process.env.DOKPLOY_FORK_VERSION = "agenthits-v1.1.0"; + await stubLatestRelease("agenthits-v1.1.0"); + + expect(await getAgentHitsUpdateData("v0.30.6")).toMatchObject({ + latestVersion: "agenthits-v1.1.0", + updateAvailable: true, + }); + }); +}); diff --git a/docs/agenthits-fork.md b/docs/agenthits-fork.md index e919431417..91a11a765b 100644 --- a/docs/agenthits-fork.md +++ b/docs/agenthits-fork.md @@ -28,8 +28,8 @@ Fork: agenthits-vX.Y.Z - Официальный `package.json` version остается semver-версией Dokploy, поэтому стандартная логика проверки upstream releases не смешивается с fork metadata. - Dashboard update check для форка сравнивает текущий Docker service digest с - `ghcr.io/agenthits/dokploy:agenthits-dev`, поэтому UI обновляет установленный - сервер из ветки `AgentHits-Dev`. + `ghcr.io/agenthits/dokploy:agenthits-stable`, поэтому UI ставит на + установленный сервер только выпущенные релизы `agenthits-vX.Y.Z`. ### Преимущества форка @@ -88,8 +88,8 @@ shows as `agenthits-vX.Y.Z--g`. - The official `package.json` version remains Dokploy's semver version, so upstream release checks are not mixed with fork metadata. - The fork dashboard update check compares the current Docker service digest - with `ghcr.io/agenthits/dokploy:agenthits-dev`, so the UI updates installed - servers from the `AgentHits-Dev` branch. + with `ghcr.io/agenthits/dokploy:agenthits-stable`, so the UI installs only + published `agenthits-vX.Y.Z` releases on installed servers. ### Fork advantages diff --git a/docs/agenthits-install.md b/docs/agenthits-install.md index b5abb0f381..769401f555 100644 --- a/docs/agenthits-install.md +++ b/docs/agenthits-install.md @@ -307,9 +307,14 @@ docker version --format '{{.Server.Version}}' `docker start dokploy-traefik` безопасен, даже если контейнер уже запущен. В dashboard кнопка `Check for updates` для AgentHits fork сравнивает текущий -Docker service image digest с `ghcr.io/agenthits/dokploy:agenthits-dev`. -Если digest отличается, обновление через UI запускает `docker service update` -на AgentHits GHCR image, а не на официальный `dokploy/dokploy`. +Docker service image digest с `ghcr.io/agenthits/dokploy:agenthits-stable`. +Этот тег указывает на последний выпущенный релиз `agenthits-vX.Y.Z`, поэтому +UI ставит только релизы, а не каждую сборку ветки `AgentHits-Dev`. Если digest +отличается и релиз не старше установленной сборки, обновление через UI запускает +`docker service update` на этот образ, закреплённый по digest, а не на +официальный `dokploy/dokploy`. Тестовая панель может следить за каждой сборкой +через переменную `DOKPLOY_AGENTHITS_UPDATE_TAG=agenthits-dev` у сервиса +`dokploy`. Обновление на конкретный immutable tag: @@ -355,7 +360,8 @@ echo "YOUR_GITHUB_TOKEN" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --passwo - Fork version обычно зашита в GitHub image build. Передавайте `DOKPLOY_FORK_VERSION` вручную только для custom/local images. - Dashboard update button в AgentHits fork обновляет из - `ghcr.io/agenthits/dokploy:agenthits-dev`. + `ghcr.io/agenthits/dokploy:agenthits-stable` (последний релиз). Перевести + кнопку на другой релиз можно ручным запуском workflow `AgentHits Stable Tag`. - Для rollback используйте тег релиза `agenthits-vX.Y.Z`. Теги `agenthits-dev-` живут только для последних ~10 сборок: старые версии образа удаляет еженедельная очистка GHCR. diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index 201fe811b5..0d03c16553 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -50,8 +50,35 @@ export const getDokployImageTag = () => { return process.env.RELEASE_TAG || "latest"; }; +// agenthits-stable names the newest published release, so Update installs +// releases only. DOKPLOY_AGENTHITS_UPDATE_TAG=agenthits-dev makes a test panel +// follow every build of AgentHits-Dev instead. export const getAgentHitsUpdateTag = () => { - return process.env.DOKPLOY_AGENTHITS_UPDATE_TAG?.trim() || "agenthits-dev"; + return process.env.DOKPLOY_AGENTHITS_UPDATE_TAG?.trim() || "agenthits-stable"; +}; + +// agenthits-vX.Y.Z is a release, agenthits-vX.Y.Z-N-g a build N commits +// after it (git describe). +const parseAgentHitsVersion = (version: string) => { + const match = /^agenthits-v(\d+)\.(\d+)\.(\d+)(?:-(\d+)-g[0-9a-f]+)?$/.exec( + version.trim(), + ); + return match ? match.slice(1).map((part) => Number(part ?? 0)) : null; +}; + +export const isOlderAgentHitsVersion = (candidate: string, current: string) => { + const candidateParts = parseAgentHitsVersion(candidate); + const currentParts = parseAgentHitsVersion(current); + if (!candidateParts || !currentParts) { + return false; + } + for (const [index, part] of candidateParts.entries()) { + const currentPart = currentParts[index] ?? 0; + if (part !== currentPart) { + return part < currentPart; + } + } + return false; }; export const getAgentHitsUpdateImage = () => { @@ -253,10 +280,17 @@ export const getAgentHitsUpdateData = async ( Boolean(currentDigest) && currentDigest !== latestImageData.latestDigest && currentDigest !== latestImageData.latestPlatformDigest; + // A panel on a build made after the newest release is not offered that older + // release: its database may already carry the build's migrations. + const latestIsOlder = isOlderAgentHitsVersion( + latestImageData.forkVersion, + currentVersionData.forkVersion, + ); return { latestVersion: latestImageData.forkVersion, - updateAvailable: digestUpdateAvailable || metadataUpdateAvailable, + updateAvailable: + !latestIsOlder && (digestUpdateAvailable || metadataUpdateAvailable), updateSource: "agenthits", latestImage: latestImageData.image, latestOfficialVersion: latestImageData.officialVersion,