diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 551e5514d49..cccb8f3b452 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -28,9 +28,10 @@ permissions: contents: read # Concurrency: push/release runs are NEVER cancelled so every merge gets its -# own SHA-tagged image; :latest is guarded separately by the move-latest job. -# PR runs reuse a PR-scoped group with cancel-in-progress: true so rapid -# pushes to the same PR collapse to the latest commit. +# own SHA-tagged image; :main and :latest are guarded separately by the +# move-main and move-latest jobs. PR runs reuse a PR-scoped group with +# cancel-in-progress: true so rapid pushes to the same PR collapse to the +# latest commit. concurrency: group: docker-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -91,10 +92,10 @@ jobs: # pattern for multi-runner multi-platform builds. # # We apply the OCI revision label here (and again on arm64) because - # the move-latest job reads it off the linux/amd64 sub-manifest config - # of `:latest` to decide whether it's safe to advance. The label must - # be on each per-arch image — manifest lists themselves don't carry - # image config labels. + # the move-main / move-latest jobs read it off the linux/amd64 + # sub-manifest config of the floating tag to decide whether it's safe + # to advance. The label must be on each per-arch image — manifest + # lists themselves don't carry image config labels. - name: Push amd64 by digest id: push if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' @@ -217,6 +218,8 @@ jobs: timeout-minutes: 10 outputs: pushed_sha_tag: ${{ steps.mark_pushed.outputs.pushed }} + pushed_release_tag: ${{ steps.mark_release_pushed.outputs.pushed }} + release_tag: ${{ steps.tag.outputs.tag }} steps: - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -271,33 +274,43 @@ jobs: IMAGE_NAME: ${{ env.IMAGE_NAME }} TAG: ${{ steps.tag.outputs.tag }} - # Signal to move-latest that the SHA tag is live. Only on main pushes; - # releases don't trigger move-latest (they use their own release tag). + # Signal to move-main that the SHA tag is live. Only on main pushes; + # releases set pushed_release_tag instead. - name: Mark SHA tag pushed id: mark_pushed if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: echo "pushed=true" >> "$GITHUB_OUTPUT" + # Signal to move-latest that the release tag is live. + - name: Mark release tag pushed + id: mark_release_pushed + if: github.event_name == 'release' + run: echo "pushed=true" >> "$GITHUB_OUTPUT" + # --------------------------------------------------------------------------- - # Move :latest to point at the SHA tag the merge job pushed. + # Move :main to point at the SHA tag the merge job pushed. + # + # :main is the floating tag that tracks the tip of the main branch. Every + # merge to main retags :main forward. Users who want "latest dev build" + # pull :main; users who want stable releases pull :latest. # # The real serialization guarantee comes from the top-level concurrency # group (`docker-${{ github.ref }}` with `cancel-in-progress: false`), # which ensures at most one workflow run for this ref executes at a time. - # That means two move-latest steps for the same ref cannot overlap. + # That means two move-main steps for the same ref cannot overlap. # # This job has its own concurrency group as defense-in-depth: if the - # top-level group is ever loosened, queued move-latests will run serially + # top-level group is ever loosened, queued move-mains will run serially # in arrival order, each one running the ancestor check below and either - # advancing :latest or skipping. `cancel-in-progress: false` matches the + # advancing :main or skipping. `cancel-in-progress: false` matches the # top-level setting — we don't want rapid pushes to cancel a queued - # move-latest, because the ancestor check is the real safety mechanism - # and queueing is cheap (move-latest is a ~30s registry op). + # move-main, because the ancestor check is the real safety mechanism + # and queueing is cheap (move-main is a ~30s registry op). # - # Combined with the ancestor check, this means :latest only ever moves + # Combined with the ancestor check, this means :main only ever moves # forward in git history. # --------------------------------------------------------------------------- - move-latest: + move-main: if: | github.repository == 'NousResearch/hermes-agent' && github.event_name == 'push' @@ -307,7 +320,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 concurrency: - group: docker-move-latest-${{ github.ref }} + group: docker-move-main-${{ github.ref }} cancel-in-progress: false steps: - name: Checkout code @@ -324,13 +337,13 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # Read the git revision label off the current :latest manifest, then + # Read the git revision label off the current :main manifest, then # use `git merge-base --is-ancestor` to check whether our commit is a - # descendant of it. If :latest doesn't exist yet, or its label is + # descendant of it. If :main doesn't exist yet, or its label is # missing, we treat that as "safe to publish". If another run already - # advanced :latest past us (or diverged), we skip and leave it alone. - - name: Decide whether to move :latest - id: latest_check + # advanced :main past us (or diverged), we skip and leave it alone. + - name: Decide whether to move :main + id: main_check run: | set -euo pipefail image=nousresearch/hermes-agent @@ -338,6 +351,119 @@ jobs: # Pull the JSON for the linux/amd64 sub-manifest's config and extract # the OCI revision label with jq — Go template field access can't # handle dots in map keys, so using json+jq is the robust route. + image_json=$( + docker buildx imagetools inspect "${image}:main" \ + --format '{{ json (index .Image "linux/amd64") }}' \ + 2>/dev/null || true + ) + + if [ -z "${image_json}" ]; then + echo "No existing :main (or inspect failed) — safe to publish." + echo "push_main=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + current_sha=$( + printf '%s' "${image_json}" \ + | jq -r '.config.Labels."org.opencontainers.image.revision" // ""' + ) + + if [ -z "${current_sha}" ]; then + echo "Registry :main has no revision label — safe to publish." + echo "push_main=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Registry :main is at ${current_sha}" + echo "This run is at ${GITHUB_SHA}" + + if [ "${current_sha}" = "${GITHUB_SHA}" ]; then + echo ":main already points at our SHA — nothing to do." + echo "push_main=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Make sure we have the :main commit locally for merge-base. + if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then + git fetch --no-tags --prune origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + || true + fi + + if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then + echo "Registry :main points at an unknown commit (${current_sha}); refusing to overwrite." + echo "push_main=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Our SHA must be a descendant of the current :main to be safe. + if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then + echo "Our commit is a descendant of :main — safe to advance." + echo "push_main=true" >> "$GITHUB_OUTPUT" + else + echo "Another run advanced :main past us (or diverged) — leaving it alone." + echo "push_main=false" >> "$GITHUB_OUTPUT" + fi + + # Retag the already-pushed SHA manifest as :main. This is a registry- + # side operation — no rebuild, no layer re-push — so it's quick and + # atomic per-tag. The ancestor check above plus the cancel-in-progress + # concurrency on this job together guarantee we only ever move :main + # forward in git history. + - name: Move :main to this SHA + if: steps.main_check.outputs.push_main == 'true' + run: | + set -euo pipefail + image=nousresearch/hermes-agent + docker buildx imagetools create \ + --tag "${image}:main" \ + "${image}:sha-${GITHUB_SHA}" + + # --------------------------------------------------------------------------- + # Move :latest to point at the release tag the merge job pushed. + # + # :latest is the floating tag that tracks the most recent stable release. + # Only `release: published` events advance it — never main pushes. + # + # We still run an ancestor check against the existing :latest so that a + # backport release on an older branch (e.g. patching v1.1.5 after v1.2.3 + # is out) doesn't drag :latest backwards. The check is the same shape as + # move-main: read the OCI revision label off the current :latest, look up + # that commit in git, and only advance if our release commit is a strict + # descendant. + # --------------------------------------------------------------------------- + move-latest: + if: | + github.repository == 'NousResearch/hermes-agent' + && github.event_name == 'release' + && needs.merge.outputs.pushed_release_tag == 'true' + needs: merge + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: docker-move-latest + cancel-in-progress: false + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 1000 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Decide whether to move :latest + id: latest_check + run: | + set -euo pipefail + image=nousresearch/hermes-agent + image_json=$( docker buildx imagetools inspect "${image}:latest" \ --format '{{ json (index .Image "linux/amd64") }}' \ @@ -362,7 +488,7 @@ jobs: fi echo "Registry :latest is at ${current_sha}" - echo "This run is at ${GITHUB_SHA}" + echo "This release is at ${GITHUB_SHA}" if [ "${current_sha}" = "${GITHUB_SHA}" ]; then echo ":latest already points at our SHA — nothing to do." @@ -371,6 +497,7 @@ jobs: fi # Make sure we have the :latest commit locally for merge-base. + # Releases can be cut from any branch, so fetch broadly. if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then git fetch --no-tags --prune origin \ "+refs/heads/main:refs/remotes/origin/main" \ @@ -383,25 +510,25 @@ jobs: exit 0 fi - # Our SHA must be a descendant of the current :latest to be safe. + # Our release SHA must be a descendant of the current :latest. + # Backport releases on older branches won't satisfy this and will + # be left alone — :latest stays on the newer release. if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then - echo "Our commit is a descendant of :latest — safe to advance." + echo "Our release commit is a descendant of :latest — safe to advance." echo "push_latest=true" >> "$GITHUB_OUTPUT" else - echo "Another run advanced :latest past us (or diverged) — leaving it alone." + echo "Existing :latest is newer than this release (likely a backport) — leaving it alone." echo "push_latest=false" >> "$GITHUB_OUTPUT" fi - # Retag the already-pushed SHA manifest as :latest. This is a registry- - # side operation — no rebuild, no layer re-push — so it's quick and - # atomic per-tag. The ancestor check above plus the cancel-in-progress - # concurrency on this job together guarantee we only ever move :latest - # forward in git history. - - name: Move :latest to this SHA + # Retag the already-pushed release manifest as :latest. + - name: Move :latest to this release tag if: steps.latest_check.outputs.push_latest == 'true' + env: + RELEASE_TAG: ${{ needs.merge.outputs.release_tag }} run: | set -euo pipefail image=nousresearch/hermes-agent docker buildx imagetools create \ --tag "${image}:latest" \ - "${image}:sha-${GITHUB_SHA}" + "${image}:${RELEASE_TAG}"