diff --git a/.github/scripts/sync-helm-chart-tags.sh b/.github/scripts/sync-helm-chart-tags.sh new file mode 100755 index 0000000000..518fe1eb61 --- /dev/null +++ b/.github/scripts/sync-helm-chart-tags.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +set -euo pipefail + +CHART_PATH="${CHART_PATH:-helm/librechat/Chart.yaml}" +DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}" +BASE_REF="${BASE_REF:-refs/remotes/origin/${DEFAULT_BRANCH}}" +BACKFILL_FROM_VERSION="${BACKFILL_FROM_VERSION:-1.9.0}" +PUSH_TAGS="${PUSH_TAGS:-false}" +TAG_PREFIX="${TAG_PREFIX:-chart-}" +GITHUB_SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}" +DISPATCH_WORKFLOW="${DISPATCH_WORKFLOW:-}" +RELEASE_EXISTING_TAG="${RELEASE_EXISTING_TAG:-}" +SEMVER_REGEX='^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?([+][0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?$' + +fail() { + printf '::error::%s\n' "$1" >&2 + exit 1 +} + +git_with_auth() { + if [ -n "${GITHUB_TOKEN:-}" ]; then + git -c "http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION: bearer ${GITHUB_TOKEN}" "$@" + return + fi + + git "$@" +} + +dispatch_release() { + tag="$1" + + if [ -z "$DISPATCH_WORKFLOW" ]; then + return + fi + + if [ -z "${GITHUB_REPOSITORY:-}" ]; then + fail "GITHUB_REPOSITORY is required to dispatch ${DISPATCH_WORKFLOW}" + fi + + if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + fail "Unexpected repository name: ${GITHUB_REPOSITORY}" + fi + + if [[ ! "$DISPATCH_WORKFLOW" =~ ^[A-Za-z0-9_.-]+[.]ya?ml$ ]]; then + fail "Unexpected workflow file: ${DISPATCH_WORKFLOW}" + fi + + token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + if [ -z "$token" ]; then + fail "GH_TOKEN or GITHUB_TOKEN is required to dispatch ${DISPATCH_WORKFLOW}" + fi + + command -v gh >/dev/null || + fail "GitHub CLI is required to dispatch ${DISPATCH_WORKFLOW}" + + GH_TOKEN="$token" gh workflow run "$DISPATCH_WORKFLOW" \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$DEFAULT_BRANCH" \ + -f "chart_tag=${tag}" +} + +version_less_than() { + left="${1%%[-+]*}" + right="${2%%[-+]*}" + + IFS=. read -r left_major left_minor left_patch <<<"$left" + IFS=. read -r right_major right_minor right_patch <<<"$right" + + if (( left_major != right_major )); then + (( left_major < right_major )) + return + fi + + if (( left_minor != right_minor )); then + (( left_minor < right_minor )) + return + fi + + (( left_patch < right_patch )) +} + +validate_chart_tag() { + tag="$1" + version="${tag#${TAG_PREFIX}}" + + git check-ref-format "refs/tags/${tag}" >/dev/null || + fail "Refusing to use invalid tag ${tag}" + + if [[ "$tag" != "${TAG_PREFIX}"* || ! "$version" =~ $SEMVER_REGEX ]]; then + fail "Chart tags must use the form ${TAG_PREFIX}, for example ${TAG_PREFIX}2.0.5" + fi +} + +dispatch_existing_tag() { + tag="$1" + + if [ -z "$tag" ]; then + return + fi + + validate_chart_tag "$tag" + + if [ "$PUSH_TAGS" != "true" ]; then + printf 'Would dispatch release workflow for existing %s.\n' "$tag" + return + fi + + if ! git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + fail "Remote tag ${tag} does not exist" + fi + + printf 'Dispatching release workflow for existing %s.\n' "$tag" + dispatch_release "$tag" +} + +chart_version_at() { + git show "${1}:${CHART_PATH}" 2>/dev/null | awk ' + /^version:[[:space:]]*/ { + value = $0 + sub(/^version:[[:space:]]*/, "", value) + sub(/[[:space:]]*#.*/, "", value) + gsub(/^[[:space:]"'\''"]+|[[:space:]"'\''"]+$/, "", value) + print value + exit + } + ' +} + +case "$PUSH_TAGS" in + true | false) ;; + *) fail "PUSH_TAGS must be true or false" ;; +esac + +if [[ ! "$BACKFILL_FROM_VERSION" =~ $SEMVER_REGEX ]]; then + fail "BACKFILL_FROM_VERSION must be a valid SemVer value" +fi + +git rev-parse --verify "${BASE_REF}^{commit}" >/dev/null || + fail "Unable to resolve ${BASE_REF}; fetch ${DEFAULT_BRANCH} before running this script" + +history_file="$(mktemp)" +versions_file="$(mktemp)" +seen_file="$(mktemp)" +missing_file="$(mktemp)" +cleanup() { + rm -f "$history_file" "$versions_file" "$seen_file" "$missing_file" +} +trap cleanup EXIT + +git log --first-parent --reverse --format=%H "$BASE_REF" -- "$CHART_PATH" >"$history_file" + +if [ ! -s "$history_file" ]; then + fail "No history found for ${CHART_PATH} on ${BASE_REF}" +fi + +while IFS= read -r commit; do + version="$(chart_version_at "$commit")" + + if [ -z "$version" ]; then + continue + fi + + if [[ ! "$version" =~ $SEMVER_REGEX ]]; then + fail "${CHART_PATH} has invalid SemVer '${version}' at ${commit}" + fi + + if version_less_than "$version" "$BACKFILL_FROM_VERSION"; then + continue + fi + + if grep -Fqx "$version" "$seen_file"; then + continue + fi + + printf '%s\n' "$version" >>"$seen_file" + printf '%s\t%s\n' "$version" "$commit" >>"$versions_file" +done <"$history_file" + +if [ ! -s "$versions_file" ]; then + fail "No chart versions found in ${CHART_PATH}" +fi + +while IFS="$(printf '\t')" read -r version commit; do + tag="${TAG_PREFIX}${version}" + + validate_chart_tag "$tag" + + if git rev-parse --quiet --verify "refs/tags/${tag}" >/dev/null; then + continue + fi + + printf '%s\t%s\n' "$tag" "$commit" >>"$missing_file" +done <"$versions_file" + +if [ ! -s "$missing_file" ]; then + printf 'All chart versions on %s already have %s tags.\n' "$BASE_REF" "$TAG_PREFIX" + dispatch_existing_tag "$RELEASE_EXISTING_TAG" + exit 0 +fi + +while IFS="$(printf '\t')" read -r tag commit; do + short_commit="$(git rev-parse --short "$commit")" + + if [ "$PUSH_TAGS" != "true" ]; then + printf 'Would create %s at %s.\n' "$tag" "$short_commit" + continue + fi + + if git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + printf 'Remote tag %s already exists; dispatching release workflow.\n' "$tag" + dispatch_release "$tag" + continue + fi + + git tag "$tag" "$commit" + + if git_with_auth push origin "refs/tags/${tag}"; then + printf 'Created %s at %s.\n' "$tag" "$short_commit" + dispatch_release "$tag" + continue + fi + + if git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + printf 'Remote tag %s was created concurrently; dispatching release workflow.\n' "$tag" + dispatch_release "$tag" + continue + fi + + fail "Failed to push ${tag}" +done <"$missing_file" + +dispatch_existing_tag "$RELEASE_EXISTING_TAG" diff --git a/.github/workflows/helmcharts.yml b/.github/workflows/helmcharts.yml index 9e08c189a7..019e4b0e15 100644 --- a/.github/workflows/helmcharts.yml +++ b/.github/workflows/helmcharts.yml @@ -5,18 +5,52 @@ on: push: tags: - "chart-*" + workflow_dispatch: + inputs: + chart_tag: + description: "Existing chart tag to release, for example chart-2.0.5" + required: true + type: string jobs: release: permissions: - contents: write + contents: read packages: write runs-on: ubuntu-latest + env: + CHART_REPOSITORY: ${{ github.repository_owner }}/librechat-chart steps: + - name: Resolve chart tag + id: chart-version + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_CHART_TAG: ${{ inputs.chart_tag || '' }} + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + CHART_TAG="$REF_NAME" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + CHART_TAG="$INPUT_CHART_TAG" + fi + + CHART_VERSION="${CHART_TAG#chart-}" + SEMVER_REGEX='^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$' + if [[ "$CHART_TAG" != chart-* || ! "$CHART_VERSION" =~ $SEMVER_REGEX ]]; then + echo "::error::Chart tags must use the form chart-, for example chart-2.0.3" + exit 1 + fi + + printf 'CHART_REF=refs/tags/%s\n' "$CHART_TAG" >> "$GITHUB_OUTPUT" + printf 'CHART_TAG=%s\n' "$CHART_TAG" >> "$GITHUB_OUTPUT" + printf 'CHART_VERSION=%s\n' "$CHART_VERSION" >> "$GITHUB_OUTPUT" + - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.chart-version.outputs.CHART_REF }} - name: Configure Git run: | @@ -35,20 +69,6 @@ jobs: cd ../librechat-rag-api helm dependency build - - name: Get Chart Version - id: chart-version - env: - REF_NAME: ${{ github.ref_name }} - run: | - set -euo pipefail - CHART_VERSION="${REF_NAME#chart-}" - SEMVER_REGEX='^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$' - if [[ "$REF_NAME" != chart-* || ! "$CHART_VERSION" =~ $SEMVER_REGEX ]]; then - echo "::error::Chart tags must use the form chart-, for example chart-2.0.3" - exit 1 - fi - printf 'CHART_VERSION=%s\n' "$CHART_VERSION" >> "$GITHUB_OUTPUT" - # Log in to GitHub Container Registry - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -63,7 +83,7 @@ jobs: uses: appany/helm-oci-chart-releaser@v0.4.2 with: name: librechat - repository: ${{ github.actor }}/librechat-chart + repository: ${{ env.CHART_REPOSITORY }} tag: ${{ steps.chart-version.outputs.CHART_VERSION }} path: helm/librechat registry: ghcr.io @@ -75,7 +95,7 @@ jobs: uses: appany/helm-oci-chart-releaser@v0.4.2 with: name: librechat-rag-api - repository: ${{ github.actor }}/librechat-chart + repository: ${{ env.CHART_REPOSITORY }} tag: ${{ steps.chart-version.outputs.CHART_VERSION }} path: helm/librechat-rag-api registry: ghcr.io diff --git a/.github/workflows/sync-helm-chart-tags.yml b/.github/workflows/sync-helm-chart-tags.yml new file mode 100644 index 0000000000..fe3b5e28d1 --- /dev/null +++ b/.github/workflows/sync-helm-chart-tags.yml @@ -0,0 +1,73 @@ +name: Sync Helm Chart Tags + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + release_existing_tag: + description: "Existing chart-* tag to dispatch if tag creation succeeded but release dispatch failed" + required: false + type: string + +permissions: + actions: write + contents: write + +concurrency: + group: sync-helm-chart-tags + cancel-in-progress: false + +jobs: + sync: + name: Sync chart tags + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + BASE_REF: refs/remotes/origin/main + BACKFILL_FROM_VERSION: 1.9.0 + CHART_PATH: helm/librechat/Chart.yaml + DEFAULT_BRANCH: main + DISPATCH_WORKFLOW: helmcharts.yml + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + RELEASE_EXISTING_TAG: ${{ inputs.release_existing_tag || '' }} + REPO_DIR: ${{ runner.temp }}/librechat + TAG_PREFIX: chart- + steps: + - name: Fetch main and tags + shell: bash + run: | + set -euo pipefail + + if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "::error::Unexpected repository name: $GITHUB_REPOSITORY" + exit 1 + fi + + if [[ "$GITHUB_SERVER_URL" != "https://github.com" ]]; then + echo "::error::Unexpected GitHub server URL: $GITHUB_SERVER_URL" + exit 1 + fi + + rm -rf "$REPO_DIR" + git init "$REPO_DIR" + cd "$REPO_DIR" + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git -c "http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION: bearer ${GITHUB_TOKEN}" \ + fetch --prune --force --tags origin \ + "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" + git checkout --detach "$BASE_REF" + + - name: Create missing chart tags + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUSH_TAGS: "true" + run: | + set -euo pipefail + cd "$REPO_DIR" + .github/scripts/sync-helm-chart-tags.sh