diff --git a/.github/workflows/knowledge-fs-ci.yml b/.github/workflows/knowledge-fs-ci.yml index e9c1896af79..14e102c1a2e 100644 --- a/.github/workflows/knowledge-fs-ci.yml +++ b/.github/workflows/knowledge-fs-ci.yml @@ -20,6 +20,8 @@ concurrency: env: CI: true + DIFY_KNOWLEDGE_FS_API_IMAGE_NAME: >- + ${{ vars.DIFY_KNOWLEDGE_FS_API_IMAGE_NAME || 'langgenius/dify-knowledge-fs-api' }} jobs: check-changes: @@ -107,6 +109,47 @@ jobs: - '.github/dependabot.yml' - '.github/workflows/knowledge-fs-ci.yml' + build: + name: Build KnowledgeFS API production image + needs: check-changes + if: needs.check-changes.outputs.knowledge-fs == 'true' || github.event_name == 'workflow_dispatch' + runs-on: depot-ubuntu-24.04-4 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Login to Docker Hub + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract KnowledgeFS image metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.DIFY_KNOWLEDGE_FS_API_IMAGE_NAME }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=ref,event=branch + type=sha,format=long + + - name: Build KnowledgeFS API image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: ./knowledge-fs + file: ./knowledge-fs/apps/api/Dockerfile + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64 + push: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} + tags: ${{ steps.meta.outputs.tags }} + quality: name: Run KnowledgeFS quality and contract gates needs: check-changes @@ -429,6 +472,7 @@ jobs: if: ${{ always() }} needs: - check-changes + - build - quality - skip runs-on: depot-ubuntu-24.04-4 @@ -436,17 +480,18 @@ jobs: - name: Finalize KnowledgeFS CI status env: EVENT_NAME: ${{ github.event_name }} + BUILD_RESULT: ${{ needs.build.result }} KNOWLEDGE_FS_CHANGED: ${{ needs.check-changes.outputs.knowledge-fs }} QUALITY_RESULT: ${{ needs.quality.result }} SKIP_RESULT: ${{ needs.skip.result }} run: | if [[ "$EVENT_NAME" == 'workflow_dispatch' || "$KNOWLEDGE_FS_CHANGED" == 'true' ]]; then - if [[ "$QUALITY_RESULT" == 'success' ]]; then - echo "KnowledgeFS checks ran successfully." + if [[ "$BUILD_RESULT" == 'success' && "$QUALITY_RESULT" == 'success' ]]; then + echo "KnowledgeFS build and checks ran successfully." exit 0 fi - echo "KnowledgeFS checks were required but finished with result: $QUALITY_RESULT" >&2 + echo "KnowledgeFS build or checks failed: build=$BUILD_RESULT quality=$QUALITY_RESULT" >&2 exit 1 fi diff --git a/knowledge-fs/scripts/github-actions-workflow.test.mjs b/knowledge-fs/scripts/github-actions-workflow.test.mjs index b77bf20932a..4b7d961705a 100644 --- a/knowledge-fs/scripts/github-actions-workflow.test.mjs +++ b/knowledge-fs/scripts/github-actions-workflow.test.mjs @@ -297,10 +297,52 @@ test("root workflow directly gates the Dify Agent KnowledgeFS core-tool callback assert.match(unitTests.run, /pytest[\s\S]*-q/); }); +test("root workflow builds the KnowledgeFS API image and publishes only trusted revisions", () => { + const build = workflowDocument.jobs.build; + assert.ok(build, "workflow is missing the KnowledgeFS API image build job"); + assert.equal(build.name, "Build KnowledgeFS API production image"); + assert.equal(build.needs, "check-changes"); + assert.match(build.if, /needs\.check-changes\.outputs\.knowledge-fs == 'true'/); + assert.match( + workflowDocument.env.DIFY_KNOWLEDGE_FS_API_IMAGE_NAME, + /vars\.DIFY_KNOWLEDGE_FS_API_IMAGE_NAME.*langgenius\/dify-knowledge-fs-api/, + ); + + const checkout = build.steps.find((step) => step.name === "Checkout code"); + const setupBuildx = build.steps.find((step) => step.name === "Set up Docker Buildx"); + const login = build.steps.find((step) => step.name === "Login to Docker Hub"); + const metadata = build.steps.find((step) => step.name === "Extract KnowledgeFS image metadata"); + const buildImage = build.steps.find((step) => step.name === "Build KnowledgeFS API image"); + + assert.equal(checkout.with["persist-credentials"], false); + assert.match(setupBuildx.uses, /^docker\/setup-buildx-action@[0-9a-f]{40}$/); + assert.match(login.uses, /^docker\/login-action@[0-9a-f]{40}$/); + assert.match(login.if, /workflow_dispatch.*push.*refs\/heads\/main/); + assert.equal(login.with.username, "${{ secrets.DOCKERHUB_USER }}"); + assert.equal(login.with.password, "${{ secrets.DOCKERHUB_TOKEN }}"); + assert.match(metadata.uses, /^docker\/metadata-action@[0-9a-f]{40}$/); + assert.equal(metadata.with.images, "${{ env.DIFY_KNOWLEDGE_FS_API_IMAGE_NAME }}"); + assert.match(metadata.with.tags, /type=raw,value=latest/); + assert.match(metadata.with.tags, /type=sha,format=long/); + assert.match(buildImage.uses, /^docker\/build-push-action@[0-9a-f]{40}$/); + assert.equal(buildImage.with.context, "./knowledge-fs"); + assert.equal(buildImage.with.file, "./knowledge-fs/apps/api/Dockerfile"); + assert.equal(buildImage.with.platforms, "linux/amd64"); + assert.match(buildImage.with.push, /workflow_dispatch.*push.*refs\/heads\/main/); + assert.equal(buildImage.with.tags, "${{ steps.meta.outputs.tags }}"); + assert.equal(buildImage.with.labels, "${{ steps.meta.outputs.labels }}"); + assert.doesNotMatch(JSON.stringify(build), /apps\/admin/); + + const final = workflowDocument.jobs.final; + assert.ok(final.needs.includes("build")); + assert.equal(final.steps[0].env.BUILD_RESULT, "${{ needs.build.result }}"); + assert.match(final.steps[0].run, /"\$BUILD_RESULT" == 'success'/); +}); + test("root workflow uses least privilege and pinned third-party actions", () => { assert.match(workflow, /^ {2}contents: read$/m); assert.match(workflow, /^ {2}pull-requests: read$/m); - assert.equal(workflow.match(/persist-credentials: false/g)?.length, 2); + assert.equal(workflow.match(/persist-credentials: false/g)?.length, 3); const usesLines = workflow .split("\n") @@ -315,6 +357,10 @@ test("root workflow uses least privilege and pinned third-party actions", () => "actions/setup-node", "astral-sh/setup-uv", "dorny/paths-filter", + "docker/build-push-action", + "docker/login-action", + "docker/metadata-action", + "docker/setup-buildx-action", "pnpm/action-setup", ]), );