diff --git a/.github/repository-dispatch/openwebui-version-published.schema.json b/.github/repository-dispatch/openwebui-version-published.schema.json new file mode 100644 index 00000000000..1277d23e94e --- /dev/null +++ b/.github/repository-dispatch/openwebui-version-published.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flexion.us/schemas/openwebui-version-published.json", + "title": "openwebui-version-published", + "description": "Cross-repo dispatch payload sent from flexion/open-webui's publish-flex-image.yml to flexion/flexion-open-webui-infra's open-version-bump-pr.yml after a successful ECR push. The single inter-repo data wire defined in the auto-upgrade pipeline phase 1 design doc.", + "type": "object", + "additionalProperties": false, + "required": ["version", "digest", "environment", "upstream_changelog_url"], + "properties": { + "version": { + "description": "Upstream open-webui release tag, verbatim (keep the leading v).", + "type": "string", + "pattern": "^v\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" + }, + "digest": { + "description": "ECR imageDigest captured at push (sha256 hex).", + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "environment": { + "description": "Target environment. Phase 1 only ever fires for dev; the receiver refuses anything else.", + "type": "string", + "enum": ["dev"] + }, + "upstream_changelog_url": { + "description": "Operator-readable link to the upstream release page.", + "type": "string", + "format": "uri", + "pattern": "^https://github\\.com/open-webui/open-webui/releases/tag/v\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" + } + } +} diff --git a/.github/workflows/publish-flex-image.yml b/.github/workflows/publish-flex-image.yml index 2e4bdb61cd0..7726e62477e 100644 --- a/.github/workflows/publish-flex-image.yml +++ b/.github/workflows/publish-flex-image.yml @@ -4,36 +4,93 @@ on: workflow_dispatch: inputs: version: - description: 'Version tag for ECR (e.g. v0.9.6). Use the upstream release tag verbatim — keep the 0. prefix.' + description: "Version tag for ECR (e.g. v0.9.6). Use the upstream release tag verbatim — keep the v prefix." required: true type: string environment: - description: 'Target environment' + description: "Target environment" required: true type: choice options: - dev - prod default: dev + skip_build_if_exists: + description: "If the tag already exists in ECR, skip build and just re-fire repository_dispatch." + required: false + type: boolean + default: false + # Push trigger: stage 2 (sync-pr-automerge.yml) squash-merges with an + # `Upstream-Tag: vX.Y.Z` trailer in the commit message. We extract that + # trailer here; if absent, this is a non-sync push (manual flex work, + # backports, etc.) and we exit cleanly. + push: + branches: [flex] permissions: id-token: write contents: read jobs: + resolve: + name: Resolve trigger and target version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.derive.outputs.version }} + environment: ${{ steps.derive.outputs.environment }} + should_publish: ${{ steps.derive.outputs.should_publish }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Derive target + id: derive + env: + EVENT: ${{ github.event_name }} + DISPATCH_VERSION: ${{ inputs.version }} + DISPATCH_ENV: ${{ inputs.environment }} + run: | + if [ "$EVENT" = "workflow_dispatch" ]; then + echo "version=${DISPATCH_VERSION}" >> "$GITHUB_OUTPUT" + echo "environment=${DISPATCH_ENV}" >> "$GITHUB_OUTPUT" + echo "should_publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # push event: extract Upstream-Tag trailer. + TAG=$(git log -1 --pretty=%B | awk '/^Upstream-Tag:/ { print $2; exit }') + if [ -z "$TAG" ]; then + echo "::notice title=Non-sync push::No Upstream-Tag trailer in HEAD commit; skipping publish." + echo "should_publish=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Upstream-Tag value '$TAG' does not match v.." + exit 1 + fi + echo "version=${TAG}" >> "$GITHUB_OUTPUT" + echo "environment=dev" >> "$GITHUB_OUTPUT" + echo "should_publish=true" >> "$GITHUB_OUTPUT" + publish: - name: Build flex@${{ github.sha }} → open-webui-${{ inputs.environment }}:${{ inputs.version }} - # ARM-native runner — matches Fargate ARM target, no QEMU emulation needed. + name: Build flex@${{ github.sha }} → open-webui-${{ needs.resolve.outputs.environment }}:${{ needs.resolve.outputs.version }} + needs: resolve + if: needs.resolve.outputs.should_publish == 'true' runs-on: ubuntu-24.04-arm env: AWS_REGION: ${{ secrets.AWS_REGION }} - REPOSITORY: open-webui-${{ inputs.environment }} + REPOSITORY: open-webui-${{ needs.resolve.outputs.environment }} + outputs: + digest: ${{ steps.describe.outputs.digest }} + version: ${{ needs.resolve.outputs.version }} + environment: ${{ needs.resolve.outputs.environment }} + published: ${{ steps.published.outputs.published }} steps: - uses: actions/checkout@v6 - uses: aws-actions/configure-aws-credentials@v6 with: - role-to-assume: ${{ inputs.environment == 'prod' && secrets.AWS_ROLE_ARN_PROD || secrets.AWS_ROLE_ARN_DEV }} + role-to-assume: ${{ needs.resolve.outputs.environment == 'prod' && secrets.AWS_ROLE_ARN_PROD || secrets.AWS_ROLE_ARN_DEV }} aws-region: ${{ env.AWS_REGION }} - uses: docker/setup-buildx-action@v4 @@ -41,43 +98,81 @@ jobs: - id: ecr uses: aws-actions/amazon-ecr-login@v2 - - name: Refuse to overwrite an existing tag + - name: Decide build vs skip + id: gate + env: + VERSION: ${{ needs.resolve.outputs.version }} + SKIP_IF_EXISTS: ${{ inputs.skip_build_if_exists }} run: | if aws ecr describe-images \ --repository-name "$REPOSITORY" \ --region "$AWS_REGION" \ - --image-ids imageTag="${{ inputs.version }}" \ + --image-ids imageTag="$VERSION" \ >/dev/null 2>&1; then - echo "::error title=Tag exists::${REPOSITORY}:${{ inputs.version }} already exists in ECR. Promotion is intentionally not idempotent — delete the existing tag manually or pick a different version." + if [ "$SKIP_IF_EXISTS" = "true" ]; then + echo "::notice title=Tag exists::Skipping build, re-firing dispatch only (operator recovery path)." + echo "skip_build=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "::error title=Tag exists::${REPOSITORY}:${VERSION} already exists in ECR. Promotion is intentionally not idempotent — delete the existing tag manually or pick a different version." exit 1 fi + echo "skip_build=false" >> "$GITHUB_OUTPUT" - name: Build and push (linux/arm64) + id: publish + if: steps.gate.outputs.skip_build != 'true' uses: docker/build-push-action@v7 with: context: . platforms: linux/arm64 push: true - tags: ${{ steps.ecr.outputs.registry }}/${{ env.REPOSITORY }}:${{ inputs.version }} + tags: ${{ steps.ecr.outputs.registry }}/${{ env.REPOSITORY }}:${{ needs.resolve.outputs.version }} build-args: | BUILD_HASH=${{ github.sha }} USE_PERMISSION_HARDENING=false - - name: Show pushed image + - name: Mark published flag + id: published + run: echo "published=true" >> "$GITHUB_OUTPUT" + + - name: Describe pushed image (capture digest) + id: describe + env: + VERSION: ${{ needs.resolve.outputs.version }} run: | + DIGEST=$(aws ecr describe-images \ + --repository-name "$REPOSITORY" \ + --region "$AWS_REGION" \ + --image-ids imageTag="$VERSION" \ + --query 'imageDetails[0].imageDigest' \ + --output text) + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" aws ecr describe-images \ --repository-name "$REPOSITORY" \ --region "$AWS_REGION" \ - --image-ids imageTag="${{ inputs.version }}" \ + --image-ids imageTag="$VERSION" \ --query 'imageDetails[0].{Digest:imageDigest,Tags:imageTags,Pushed:imagePushedAt,SizeBytes:imageSizeInBytes}' \ --output table - - name: Next-step reminder - run: | - cat <> "$GITHUB_OUTPUT" + exit 0 + fi + HEAD_SHA="${{ github.event.check_suite.head_sha }}" + NUMS=$(gh pr list --state open --search "in:title chore: upstream-sync" \ + --json number,headRefOid,headRefName \ + --jq "[.[] | select(.headRefOid == \"$HEAD_SHA\" and (.headRefName | startswith(\"upstream-sync/\"))) | .number] | join(\",\")") + echo "numbers=${NUMS}" >> "$GITHUB_OUTPUT" + + - name: Evaluate and merge + if: steps.prs.outputs.numbers != '' + env: + GH_TOKEN: ${{ secrets.SYNC_PAT }} + BOT_LOGIN: flex-upstream-sync-bot + NUMS: ${{ steps.prs.outputs.numbers }} + run: | + set -e + # 15-minute grace window — gives late-arriving check suites time to + # land before we merge. Without this, merging on the first green + # check could race a slow lint job. + GRACE_SECONDS=900 + + IFS=',' read -ra ARR <<< "$NUMS" + for NUM in "${ARR[@]}"; do + [ -z "$NUM" ] && continue + echo "::group::Evaluating PR #$NUM" + DATA=$(gh pr view "$NUM" --json author,title,body,headRefName,isDraft,mergeable,mergeStateStatus,createdAt) + AUTHOR=$(echo "$DATA" | jq -r '.author.login') + TITLE=$(echo "$DATA" | jq -r '.title') + BODY=$(echo "$DATA" | jq -r '.body') + HEAD=$(echo "$DATA" | jq -r '.headRefName') + DRAFT=$(echo "$DATA" | jq -r '.isDraft') + MERGEABLE=$(echo "$DATA" | jq -r '.mergeable') + STATE=$(echo "$DATA" | jq -r '.mergeStateStatus') + CREATED=$(echo "$DATA" | jq -r '.createdAt') + + if [ "$AUTHOR" != "$BOT_LOGIN" ] && [ "$AUTHOR" != "app/${BOT_LOGIN}" ]; then + echo "::notice::author $AUTHOR is not the bot — skipping" + echo "::endgroup::"; continue + fi + case "$HEAD" in + upstream-sync/*) : ;; + *) + echo "::notice::head $HEAD does not match upstream-sync/* — skipping" + echo "::endgroup::"; continue ;; + esac + if [ "$DRAFT" = "true" ]; then + echo "::notice::draft — skipping (manual conflicts present)" + echo "::endgroup::"; continue + fi + + # The rebase step writes "Files needing manual review (N)" into + # the PR body. We only auto-merge when N == 0. The body of a + # clean rebase contains "## Clean rebase". + if ! echo "$BODY" | grep -q "## Clean rebase"; then + echo "::notice::PR body does not show a clean rebase — skipping" + echo "::endgroup::"; continue + fi + + if [ "$MERGEABLE" != "MERGEABLE" ] || [ "$STATE" != "CLEAN" ]; then + echo "::notice::not yet mergeable (mergeable=$MERGEABLE state=$STATE)" + echo "::endgroup::"; continue + fi + + CREATED_TS=$(date -d "$CREATED" +%s) + NOW_TS=$(date -u +%s) + AGE=$((NOW_TS - CREATED_TS)) + if [ "$AGE" -lt "$GRACE_SECONDS" ]; then + echo "::notice::PR age ${AGE}s < grace ${GRACE_SECONDS}s — waiting for late checks" + echo "::endgroup::"; continue + fi + + # Squash-merge with an Upstream-Tag trailer so stage 3 (publish) + # can extract the target tag from the merged commit message. + TAG=$(echo "$TITLE" | sed -E 's/.*onto (v[0-9]+\.[0-9]+\.[0-9]+).*/\1/') + if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Could not extract upstream tag from title: $TITLE" + echo "::endgroup::"; continue + fi + COMMIT_MSG=$(printf 'chore: upstream-sync flex onto %s\n\nUpstream-Tag: %s\n' "$TAG" "$TAG") + + echo "PR #$NUM eligible — squash-merging with Upstream-Tag: $TAG" + gh pr merge "$NUM" --squash --delete-branch \ + --subject "chore: upstream-sync flex onto ${TAG} (#${NUM})" \ + --body "Upstream-Tag: ${TAG}" + echo "::endgroup::" + done diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 25906e9beb7..f4efc46e96e 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -101,8 +101,80 @@ jobs: echo "Will sync flex from ${BASE:-} → ${TARGET}" echo "sync=true" >> "$GITHUB_OUTPUT" + # Cooldown gate (auto-upgrade design phase 1, stage 1 extension). + # Source of truth for "release age" is the GitHub Release object's + # published_at — not the git tag's taggerdate, which can predate the + # public release by hours or days. + # - patch: 3-day soak before auto-PR + # - minor: 7-day soak before auto-PR + # - major: never auto-PR (blocked → operator opens issue) + # - pre-release: never auto-PR (suffix on tag blocks regardless) + # `workflow_dispatch` with `force=true` (or explicit target_tag) bypasses. + - name: Cooldown gate + if: steps.decide.outputs.sync == 'true' && inputs.force != true + id: cooldown + env: + TARGET: ${{ steps.target.outputs.target }} + BASE: ${{ steps.base.outputs.base }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + + if [[ "$TARGET" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+$ ]]; then + echo "::notice title=Pre-release blocked::${TARGET} carries a pre-release suffix. Auto-PR refused; operator can target via workflow_dispatch." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! [[ "$TARGET" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "::error::Target ${TARGET} does not match v.." + exit 1 + fi + T_MAJ="${BASH_REMATCH[1]}"; T_MIN="${BASH_REMATCH[2]}"; T_PAT="${BASH_REMATCH[3]}" + + if [ -z "$BASE" ]; then + echo "::notice title=No base::First-time sync — treated as major bump, blocking auto-PR." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! [[ "$BASE" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "::error::Base ${BASE} does not match v.." + exit 1 + fi + B_MAJ="${BASH_REMATCH[1]}"; B_MIN="${BASH_REMATCH[2]}" + + if [ "$T_MAJ" -ne "$B_MAJ" ]; then + echo "::notice title=Major bump blocked::${BASE} → ${TARGET} crosses a major boundary. Auto-PR refused." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + TIER="patch" + WINDOW=$((3 * 24 * 60 * 60)) + if [ "$T_MIN" -ne "$B_MIN" ]; then + TIER="minor" + WINDOW=$((7 * 24 * 60 * 60)) + fi + + PUBLISHED_AT=$(gh api "repos/open-webui/open-webui/releases/tags/${TARGET}" --jq '.published_at' 2>/dev/null || true) + if [ -z "$PUBLISHED_AT" ] || [ "$PUBLISHED_AT" = "null" ]; then + echo "::notice title=No release object::No GitHub Release exists for ${TARGET} yet. Holding for cooldown to start." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + PUBLISHED_TS=$(date -d "$PUBLISHED_AT" +%s) + NOW_TS=$(date -u +%s) + AGE=$((NOW_TS - PUBLISHED_TS)) + + if [ "$AGE" -lt "$WINDOW" ]; then + echo "::notice title=Cooldown active::${TARGET} (${TIER}) released ${AGE}s ago; window is ${WINDOW}s. Re-evaluate next tick." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Cooldown satisfied: ${TARGET} (${TIER}) age=${AGE}s window=${WINDOW}s" + echo "skip=false" >> "$GITHUB_OUTPUT" + - name: Create throwaway branch - if: steps.decide.outputs.sync == 'true' + if: steps.decide.outputs.sync == 'true' && steps.cooldown.outputs.skip != 'true' id: branch env: TARGET: ${{ steps.target.outputs.target }} @@ -112,7 +184,7 @@ jobs: git checkout -b "${BRANCH}" - name: Rebase onto target tag with deterministic conflict rules - if: steps.decide.outputs.sync == 'true' + if: steps.decide.outputs.sync == 'true' && steps.cooldown.outputs.skip != 'true' id: rebase env: TARGET: ${{ steps.target.outputs.target }} @@ -187,7 +259,7 @@ jobs: fi - name: Push throwaway branch and open PR - if: steps.decide.outputs.sync == 'true' + if: steps.decide.outputs.sync == 'true' && steps.cooldown.outputs.skip != 'true' env: GH_TOKEN: ${{ secrets.SYNC_PAT }} BRANCH: ${{ steps.branch.outputs.branch }}