Release container #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release container | |
| run-name: Release container | |
| on: | |
| schedule: | |
| # 13:00 GMT+8 (05:00 UTC) every day. GitHub Actions cron uses UTC. | |
| - cron: "0 5 * * *" | |
| release: | |
| types: [published] | |
| workflow_call: | |
| inputs: | |
| source_ref: | |
| description: "Git tag to build" | |
| required: true | |
| type: string | |
| image_tag: | |
| description: "Docker image tag" | |
| required: true | |
| type: string | |
| release_build: | |
| description: "Require source_ref to resolve as a Git tag" | |
| required: true | |
| type: boolean | |
| workflow_dispatch: | |
| inputs: | |
| source_ref: | |
| description: "Git tag, commit SHA, or branch to build" | |
| required: true | |
| type: string | |
| default: main | |
| image_tag: | |
| description: "Docker image tag override (defaults to the source ref)" | |
| required: false | |
| type: string | |
| concurrency: | |
| group: release-container | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| jobs: | |
| authorize-publication: | |
| name: Authorize publication | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Verify manual publisher permission | |
| if: github.event_name == 'workflow_dispatch' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| permission="$(gh api \ | |
| "repos/$GITHUB_REPOSITORY/collaborators/$GITHUB_ACTOR/permission" \ | |
| --jq '.role_name')" | |
| case "$permission" in | |
| admin|maintain) | |
| echo "$GITHUB_ACTOR has $permission permission" | |
| ;; | |
| *) | |
| echo "::error::Manual container publication requires admin or maintain permission" | |
| exit 1 | |
| ;; | |
| esac | |
| publish: | |
| name: Build and publish Docker image | |
| needs: authorize-publication | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| statuses: write | |
| environment: docker-publish | |
| steps: | |
| - name: Verify configuration | |
| env: | |
| DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} | |
| DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} | |
| DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$DOCKERHUB_IMAGE" ]; then | |
| echo "::error::The DOCKERHUB_IMAGE environment variable is not configured" | |
| exit 1 | |
| fi | |
| if [ -z "$DOCKERHUB_USERNAME" ] || [ -z "$DOCKERHUB_TOKEN" ]; then | |
| echo "::error::The Docker Hub environment secrets are not configured" | |
| exit 1 | |
| fi | |
| if [[ ! "$DOCKERHUB_IMAGE" =~ ^[a-z0-9]+([._-][a-z0-9]+)*/[a-z0-9]+([._-][a-z0-9]+)*$ ]]; then | |
| echo "::error::DOCKERHUB_IMAGE must be a Docker Hub repository such as vllm/agentic-api" | |
| exit 1 | |
| fi | |
| - name: Select source | |
| id: release | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| RELEASE_TAG: ${{ github.event.release.tag_name }} | |
| REQUESTED_REF: ${{ inputs.source_ref }} | |
| REQUESTED_IMAGE_TAG: ${{ inputs.image_tag }} | |
| RELEASE_BUILD: ${{ inputs.release_build }} | |
| run: | | |
| set -euo pipefail | |
| # Reusable workflows inherit the caller's event name. | |
| if [ "$RELEASE_BUILD" = "true" ]; then | |
| source_ref="refs/tags/$REQUESTED_REF" | |
| elif [ "$EVENT_NAME" = "schedule" ]; then | |
| source_ref="main" | |
| elif [ "$EVENT_NAME" = "release" ]; then | |
| source_ref="refs/tags/$RELEASE_TAG" | |
| else | |
| source_ref="$REQUESTED_REF" | |
| fi | |
| if [[ "$source_ref" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| : | |
| elif [[ "$source_ref" =~ ^[0-9a-fA-F]{7,39}$ ]]; then | |
| echo "::error::Commit SHAs must contain all 40 hexadecimal characters" | |
| exit 1 | |
| elif ! git check-ref-format --allow-onelevel "$source_ref"; then | |
| echo "::error::Invalid Git source ref" | |
| exit 1 | |
| fi | |
| echo "source_ref=$source_ref" >> "$GITHUB_OUTPUT" | |
| - name: Check out release source | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ steps.release.outputs.source_ref }} | |
| - name: Resolve source commit | |
| id: source | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| RELEASE_TAG: ${{ github.event.release.tag_name }} | |
| REQUESTED_IMAGE_TAG: ${{ inputs.image_tag }} | |
| REQUESTED_REF: ${{ inputs.source_ref }} | |
| RELEASE_BUILD: ${{ inputs.release_build }} | |
| DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -n "$REQUESTED_IMAGE_TAG" \ | |
| && ! "$REQUESTED_IMAGE_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then | |
| echo "::error::Invalid Docker image tag override" | |
| exit 1 | |
| fi | |
| sha="$(git rev-parse HEAD)" | |
| git fetch --no-tags origin main | |
| main_sha="$(git rev-parse FETCH_HEAD)" | |
| image_tags=() | |
| nightly=false | |
| if [ "$RELEASE_BUILD" = "true" ]; then | |
| image_tags+=("$REQUESTED_IMAGE_TAG") | |
| elif [ "$EVENT_NAME" = "release" ]; then | |
| image_tags+=("$RELEASE_TAG") | |
| elif [ "$EVENT_NAME" = "schedule" ] \ | |
| || { [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$sha" = "$main_sha" ]; }; then | |
| nightly=true | |
| image_tags+=("nightly-$sha" "nightly") | |
| if [ -n "$REQUESTED_IMAGE_TAG" ]; then | |
| image_tags+=("$REQUESTED_IMAGE_TAG") | |
| fi | |
| else | |
| # Normalize slashes only in the default tag derived from a Git ref. | |
| image_tags+=("${REQUESTED_IMAGE_TAG:-${REQUESTED_REF//\//-}}") | |
| fi | |
| tags=() | |
| for image_tag in "${image_tags[@]}"; do | |
| if [[ ! "$image_tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then | |
| echo "::error::Invalid Docker image tag" | |
| exit 1 | |
| fi | |
| tags+=("$DOCKERHUB_IMAGE:$image_tag") | |
| done | |
| version="${image_tags[0]}" | |
| echo "sha=$sha" >> "$GITHUB_OUTPUT" | |
| echo "nightly=$nightly" >> "$GITHUB_OUTPUT" | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "tags<<EOF" | |
| printf '%s\n' "${tags[@]}" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| printf 'Publishing %s\n' "${tags[@]}" | |
| - name: Check for an already published nightly commit | |
| id: nightly-check | |
| if: steps.source.outputs.nightly == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| SOURCE_SHA: ${{ steps.source.outputs.sha }} | |
| DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} | |
| run: | | |
| set -euo pipefail | |
| status_context="container/nightly/$DOCKERHUB_IMAGE" | |
| # Read GitHub's publication record, without querying Docker Hub. API | |
| # failures must stop the run rather than be interpreted as a cache miss. | |
| statuses="$(gh api --paginate --method GET \ | |
| "repos/$GITHUB_REPOSITORY/commits/$SOURCE_SHA/statuses" \ | |
| -f per_page=100 --jq '.[] | [.context, .state] | @tsv')" | |
| skip=false | |
| # The API returns newest statuses first; use the latest for this image. | |
| while IFS=$'\t' read -r context state; do | |
| if [ "$context" = "$status_context" ]; then | |
| if [ "$state" = "success" ]; then | |
| skip=true | |
| fi | |
| break | |
| fi | |
| done <<< "$statuses" | |
| echo "skip=$skip" >> "$GITHUB_OUTPUT" | |
| if [ "$skip" = "true" ]; then | |
| message="Skipping nightly: $DOCKERHUB_IMAGE at $SOURCE_SHA was already published." | |
| echo "$message" | |
| echo "$message" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Set up Docker Buildx | |
| if: steps.nightly-check.outputs.skip != 'true' | |
| uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 | |
| - name: Generate image metadata | |
| if: steps.nightly-check.outputs.skip != 'true' | |
| id: image-metadata | |
| run: echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" | |
| # Pull public build dependencies anonymously. The publishing token may only | |
| # have access to the destination repository, not docker/dockerfile or base images. | |
| - name: Build runtime image | |
| if: steps.nightly-check.outputs.skip != 'true' | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 | |
| with: | |
| context: . | |
| push: false | |
| load: true | |
| tags: ${{ steps.source.outputs.tags }} | |
| build-args: | | |
| OCI_BUILD_PIPELINE=${{ github.workflow }} | |
| OCI_BUILD_URL=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| OCI_CREATED=${{ steps.image-metadata.outputs.created }} | |
| OCI_REVISION=${{ steps.source.outputs.sha }} | |
| OCI_VERSION=${{ steps.source.outputs.version }} | |
| cache-from: type=gha,scope=agentic-api-container | |
| cache-to: type=gha,mode=max,scope=agentic-api-container,ignore-error=true | |
| - name: Log in to Docker Hub for publication | |
| if: steps.nightly-check.outputs.skip != 'true' | |
| uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v4.0.0 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Push runtime image | |
| if: steps.nightly-check.outputs.skip != 'true' | |
| env: | |
| IMAGE_TAGS: ${{ steps.source.outputs.tags }} | |
| run: | | |
| set -euo pipefail | |
| while IFS= read -r tag; do | |
| docker push "$tag" | |
| done <<< "$IMAGE_TAGS" | |
| - name: Record successful nightly publication | |
| if: steps.source.outputs.nightly == 'true' && steps.nightly-check.outputs.skip != 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| SOURCE_SHA: ${{ steps.source.outputs.sha }} | |
| DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} | |
| run: | | |
| set -euo pipefail | |
| gh api --method POST "repos/$GITHUB_REPOSITORY/statuses/$SOURCE_SHA" \ | |
| -f state=success \ | |
| -f context="container/nightly/$DOCKERHUB_IMAGE" \ | |
| -f description='All nightly image tags successfully pushed' \ | |
| -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ | |
| --silent | |
| - name: Report disabled nightly cleanup | |
| if: vars.DOCKERHUB_CLEANUP_ENABLED != 'true' | |
| run: | | |
| echo "::warning::Nightly cleanup is disabled; 30-day retention is not enforced." | |
| echo "Enable DOCKERHUB_CLEANUP_ENABLED only with tag-read and tag-delete permissions." | |
| - name: Delete nightly tags older than 30 days | |
| if: vars.DOCKERHUB_CLEANUP_ENABLED == 'true' | |
| env: | |
| DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE }} | |
| DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} | |
| DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} | |
| run: | | |
| set +x | |
| set -euo pipefail | |
| namespace="${DOCKERHUB_IMAGE%%/*}" | |
| repository="${DOCKERHUB_IMAGE#*/}" | |
| cutoff="$(date -u --date='30 days ago' '+%Y-%m-%dT%H:%M:%SZ')" | |
| # Personal access tokens are exchanged for an API bearer token. | |
| # Organization access tokens can be used directly as bearer tokens. | |
| auth_payload="$(jq --null-input \ | |
| '{identifier: env.DOCKERHUB_USERNAME, secret: env.DOCKERHUB_TOKEN}')" | |
| if api_token="$(printf '%s' "$auth_payload" \ | |
| | curl --fail --silent --show-error \ | |
| --request POST 'https://hub.docker.com/v2/auth/token' \ | |
| --header 'Content-Type: application/json' \ | |
| --data-binary @- \ | |
| | jq --exit-status --raw-output '.access_token')"; then | |
| : | |
| else | |
| api_token="$DOCKERHUB_TOKEN" | |
| fi | |
| printf '::add-mask::%s\n' "$api_token" | |
| unset auth_payload | |
| api_base="https://hub.docker.com/v2/namespaces/$namespace/repositories/$repository" | |
| url="$api_base/tags?page_size=100&name=nightly-" | |
| candidates=() | |
| while [ "$url" != "null" ] && [ -n "$url" ]; do | |
| case "$url" in | |
| "$api_base/tags?"*) ;; | |
| *) | |
| echo "::error::Docker Hub returned an unexpected pagination URL" | |
| exit 1 | |
| ;; | |
| esac | |
| response="$(curl --fail --silent --show-error \ | |
| --header "Authorization: Bearer $api_token" "$url")" | |
| mapfile -t page_candidates < <(jq --raw-output --arg cutoff "$cutoff" ' | |
| .results[] | |
| | select(.name | startswith("nightly-")) | |
| | select(.last_updated < $cutoff) | |
| | .name | |
| ' <<< "$response") | |
| candidates+=("${page_candidates[@]}") | |
| url="$(jq --raw-output '.next' <<< "$response")" | |
| done | |
| for tag in "${candidates[@]}"; do | |
| echo "Deleting expired Docker tag $DOCKERHUB_IMAGE:$tag" | |
| curl --fail --silent --show-error \ | |
| --output /dev/null \ | |
| --request DELETE \ | |
| --header "Authorization: Bearer $api_token" \ | |
| "$api_base/tags/$tag" | |
| done |