diff --git a/.github/workflows/docker-promote-stable.yml b/.github/workflows/docker-promote-stable.yml new file mode 100644 index 0000000000..dc47dc05f5 --- /dev/null +++ b/.github/workflows/docker-promote-stable.yml @@ -0,0 +1,43 @@ +name: Promote published Docker image + +# Moves nousresearch/hermes-agent:stable/latest (and -desktop) onto the image a +# published vX.Y.Z receipt tag binds. The ordered publication pass does this on +# its own; dispatch this when that pass cannot reach the alias step. +on: + workflow_dispatch: + inputs: + version: + description: 'Published stable version (for example 0.21.6)' + required: true + type: string + +concurrency: + group: stable-release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + promote: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: release-signing + steps: + - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + persist-credentials: false + - uses: ./.github/actions/setup-pm + with: + cache-python: false + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Move stable/latest onto the receipt-bound image + env: + VERSION: ${{ inputs.version }} + run: python -m scripts.releases.docker promote --version "$VERSION" diff --git a/scripts/releases/docker.py b/scripts/releases/docker.py index d8e8622af4..de72c8f207 100644 --- a/scripts/releases/docker.py +++ b/scripts/releases/docker.py @@ -170,6 +170,26 @@ def published_digest(tag: str, run=output) -> str: return digest +def promote_published(version: str, *, run=output) -> None: + """Move the aliases from the digest a published ``vX.Y.Z`` receipt binds. + + The ordered publication pass normally does this after the R2 channel head + moves; this re-enters only the alias step, from the receipt alone. + """ + from scripts.releases.versioning import tag_record + + tag = f"v{version}" + if not STABLE_TAG_RE.fullmatch(tag): + raise DockerReleaseError(f"Not a stable version: {version!r}") + run(["git", "fetch", "origin", f"+refs/tags/{tag}:refs/tags/{tag}"]) + if run(["git", "cat-file", "-t", f"refs/tags/{tag}"]) != "tag": + raise DockerReleaseError(f"{tag} is not an annotated receipt tag") + receipt = tag_record(run(["git", "tag", "-l", tag, "--format=%(contents)"])) + if not isinstance(receipt, dict) or receipt.get("version") != version: + raise DockerReleaseError(f"{tag} carries no release receipt") + promote_stable(receipt["claimTag"], receipt["dockerManifestDigest"], run=run) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command", required=True) @@ -187,9 +207,14 @@ def main(argv: list[str] | None = None) -> int: p_verify.add_argument("--commit", required=True) p_verify.add_argument("manifest", help="Path to the downloaded manifest JSON") + p_promote = sub.add_parser("promote", help="Move stable/latest onto a published release's receipt-bound image") + p_promote.add_argument("--version", required=True, help="Published version, for example 0.21.6") + args = parser.parse_args(argv) try: - if args.command == "manifest": + if args.command == "promote": + promote_published(args.version) + elif args.command == "manifest": archive_hashes = {} for arch, path in (("amd64", args.archive_amd64), ("arm64", args.archive_arm64)): if path: @@ -206,7 +231,7 @@ def main(argv: list[str] | None = None) -> int: manifest = parse_manifest(handle.read()) verify_manifest(manifest, args.tag, args.commit) print(json.dumps(manifest)) - except DockerReleaseError as exc: + except (DockerReleaseError, subprocess.CalledProcessError) as exc: print(f"::error::{exc}", file=sys.stderr) return 1 return 0