diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9f1807..40791e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,20 @@ name: ci on: push: branches: [main] + paths: + - "apps/**" + - "packages/**" + - "infra/**" + - "scripts/**" + - "ops/**" + - ".github/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - "biome.json" + - "vitest.config.ts" + - "tsconfig*.json" pull_request: permissions: @@ -15,8 +29,39 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + changes: + name: Detect product changes + runs-on: [self-hosted, evoo-ci, Linux] + outputs: + product: ${{ steps.filter.outputs.product }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + fetch-depth: 0 + - uses: dorny/paths-filter@451258540fd6e81b87561f5d5adbd9863fc87cee # v3.0.2 + id: filter + with: + filters: | + product: + - 'apps/**' + - 'packages/**' + - 'infra/**' + - 'scripts/**' + - 'ops/**' + - '.github/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'turbo.json' + - 'biome.json' + - 'vitest.config.ts' + - 'tsconfig*.json' + lint: name: Lint + needs: changes + if: needs.changes.outputs.product == 'true' runs-on: [self-hosted, evoo-ci, Linux] steps: - uses: actions/checkout@v5 @@ -51,6 +96,8 @@ jobs: check: name: Typecheck + needs: changes + if: needs.changes.outputs.product == 'true' runs-on: [self-hosted, evoo-ci, Linux] steps: - uses: actions/checkout@v5 @@ -88,6 +135,8 @@ jobs: build: name: Production builds + needs: changes + if: needs.changes.outputs.product == 'true' runs-on: [self-hosted, evoo-ci, Linux] timeout-minutes: 10 steps: @@ -138,6 +187,8 @@ jobs: test: name: Unit tests + needs: changes + if: needs.changes.outputs.product == 'true' runs-on: [self-hosted, evoo-ci, Linux] steps: - uses: actions/checkout@v5 @@ -175,6 +226,8 @@ jobs: test-integration: name: Postgres journeys + needs: changes + if: needs.changes.outputs.product == 'true' runs-on: [self-hosted, evoo-ci, Linux] timeout-minutes: 10 steps: @@ -210,6 +263,8 @@ jobs: test-e2e: name: Web E2E + needs: changes + if: needs.changes.outputs.product == 'true' uses: ./.github/workflows/playwright.yml with: upload_artifacts: true @@ -217,11 +272,12 @@ jobs: product-gate: name: Product CI if: always() - needs: [lint, check, build, test, test-integration, test-e2e] + needs: [changes, lint, check, build, test, test-integration, test-e2e] runs-on: [self-hosted, evoo-ci, Linux] steps: - name: Verify product checks env: + PRODUCT: ${{ needs.changes.outputs.product }} LINT: ${{ needs.lint.result }} CHECK: ${{ needs.check.result }} BUILD: ${{ needs.build.result }} @@ -230,11 +286,18 @@ jobs: TEST_E2E: ${{ needs.test-e2e.result }} run: | set -euo pipefail + if [[ "$PRODUCT" != "true" ]]; then + echo "No product-impacting paths changed; Product CI satisfied." + exit 0 + fi failed=0 for name in LINT CHECK BUILD TEST TEST_INTEGRATION TEST_E2E; do result="${!name}" printf '%s=%s\n' "$name" "$result" - if [[ "$result" != "success" ]]; then + if [[ "$result" == "skipped" ]]; then + echo "$name was skipped although product paths changed." >&2 + failed=1 + elif [[ "$result" != "success" ]]; then failed=1 fi done @@ -246,9 +309,9 @@ jobs: publish-mobile-update: name: Publish compatible mobile update - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.product == 'true' continue-on-error: true - needs: [lint, check, build, test, test-integration, test-e2e] + needs: [changes, lint, check, build, test, test-integration, test-e2e] runs-on: [self-hosted, evoo-ci, Linux] timeout-minutes: 20 concurrency: diff --git a/.github/workflows/publish-server-image.yml b/.github/workflows/publish-server-image.yml index b55cda2..76d69a4 100644 --- a/.github/workflows/publish-server-image.yml +++ b/.github/workflows/publish-server-image.yml @@ -6,6 +6,17 @@ name: publish-server-image on: push: branches: [main] + paths: + - ".dockerignore" + - ".github/workflows/publish-server-image.yml" + - "apps/**" + - "infra/compose/**" + - "infra/sandboxes/computer/**" + - "infra/updater/**" + - "packages/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" tags: ["v*"] pull_request: paths: @@ -31,10 +42,51 @@ env: DOCKER_BUILD_RECORD_UPLOAD: false concurrency: - group: publish-server-image-${{ github.ref }} - cancel-in-progress: false + group: publish-server-image-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + changes: + name: Detect image publish inputs + runs-on: [self-hosted, evoo-ci, Linux] + outputs: + publish: ${{ steps.decide.outputs.publish }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + with: + persist-credentials: false + fetch-depth: 0 + - uses: dorny/paths-filter@451258540fd6e81b87561f5d5adbd9863fc87cee # v3.0.2 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + id: filter + with: + filters: | + publish: + - '.dockerignore' + - '.github/workflows/publish-server-image.yml' + - 'apps/**' + - 'infra/compose/**' + - 'infra/sandboxes/computer/**' + - 'infra/updater/**' + - 'packages/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - name: Decide whether release jobs should run + id: decide + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "${{ github.ref }}" == refs/tags/v* ]]; then + echo "publish=true" >> "$GITHUB_OUTPUT" + elif [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "publish=true" >> "$GITHUB_OUTPUT" + elif [[ "${{ steps.filter.outputs.publish }}" == "true" ]]; then + echo "publish=true" >> "$GITHUB_OUTPUT" + else + echo "publish=false" >> "$GITHUB_OUTPUT" + fi + installer-smoke: name: Compose installer smokes if: github.event_name == 'pull_request' @@ -97,13 +149,17 @@ jobs: build: name: Build ${{ matrix.name }} ${{ matrix.arch }} - if: github.event_name != 'pull_request' + needs: changes + if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true' # Each architecture builds natively on its own runner and pushes by digest only. # amd64 on evo Linux runners. arm64 is paused: the macOS runners have no Docker buildx, # and QEMU emulation took hours for the desktop-sized computer image. # See docs/self-host.md (Published images and tags). runs-on: ${{ matrix.runner }} timeout-minutes: 90 + concurrency: + group: publish-server-image-build-${{ github.ref }} + cancel-in-progress: ${{ github.ref == 'refs/heads/main' }} permissions: contents: read packages: write @@ -174,10 +230,13 @@ jobs: publish: name: Publish ${{ matrix.name }} image - if: github.event_name != 'pull_request' - needs: build + if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true' + needs: [changes, build] runs-on: [self-hosted, evoo-ci, Linux] timeout-minutes: 15 + concurrency: + group: publish-server-image-publish-${{ github.ref == 'refs/heads/main' && 'main' || github.ref }} + cancel-in-progress: false permissions: contents: read packages: write diff --git a/infra/updater/src/ci-workflow.test.ts b/infra/updater/src/ci-workflow.test.ts index ddbaae1..39fda10 100644 --- a/infra/updater/src/ci-workflow.test.ts +++ b/infra/updater/src/ci-workflow.test.ts @@ -22,6 +22,7 @@ describe("ci workflow merge gate", () => { it("aggregates only product jobs in Product CI", () => { const productGate = workflow.jobs["product-gate"]; expect(productGate?.needs).toEqual([ + "changes", "lint", "check", "build", @@ -29,6 +30,8 @@ describe("ci workflow merge gate", () => { "test-integration", "test-e2e", ]); + expect(workflowText).toContain("needs.changes.outputs.product == 'true'"); + expect(workflowText).toContain("No product-impacting paths changed; Product CI satisfied."); expect(productGate?.if).toBe("always()"); }); diff --git a/infra/updater/src/publish-workflow.test.ts b/infra/updater/src/publish-workflow.test.ts index e7a8d18..0c4b6b1 100644 --- a/infra/updater/src/publish-workflow.test.ts +++ b/infra/updater/src/publish-workflow.test.ts @@ -35,7 +35,7 @@ describe("server image publish workflow", () => { it("publishes one verified manifest per image after the build", () => { const publish = workflow.jobs.publish; - expect(publish.needs).toBe("build"); + expect(publish.needs).toEqual(["changes", "build"]); expect(workflowText).toContain("push-by-digest=true"); expect(workflowText).toContain("docker buildx imagetools create"); expect(workflowText).toContain(`if [[ "\${#sources[@]}" -ne 1 ]]`); @@ -75,8 +75,15 @@ describe("server image publish workflow", () => { const build = workflow.jobs.build; const publish = workflow.jobs.publish; expect(validate.if).toBe("github.event_name == 'pull_request'"); - expect(build.if).toBe("github.event_name != 'pull_request'"); - expect(publish.if).toBe("github.event_name != 'pull_request'"); + expect(build.if).toBe( + "github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'", + ); + expect(publish.if).toBe( + "github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'", + ); + expect(workflowText).toContain("cancel-in-progress: ${{ github.event_name == 'pull_request' }}"); + expect(workflowText).toContain("publish-server-image-build-${{ github.ref }}"); + expect(workflowText).toContain("publish-server-image-publish-${{ github.ref == 'refs/heads/main' && 'main' || github.ref }}"); expect(workflowText).toContain("push: false"); for (const match of workflowText.matchAll(/uses:\s+([^\s#]+)/g)) { expect(match[1], match[1]).toMatch(/@[0-9a-f]{40}$/);