diff --git a/.github/workflows/autopilot-auto-merge.yml b/.github/workflows/autopilot-auto-merge.yml index 9a8e54d..938b7da 100644 --- a/.github/workflows/autopilot-auto-merge.yml +++ b/.github/workflows/autopilot-auto-merge.yml @@ -21,7 +21,7 @@ concurrency: jobs: auto-merge: name: Autopilot auto-merge - runs-on: [self-hosted, evoo-ci, Linux] + runs-on: [self-hosted, evoo-ci] steps: - name: Resolve pull request id: pr @@ -71,7 +71,10 @@ jobs: HEAD_SHA: ${{ steps.pr.outputs.head_sha }} run: | set -euo pipefail - mapfile -t conclusions < <( + conclusions=() + while IFS= read -r conclusion; do + [[ -n "$conclusion" ]] && conclusions+=("$conclusion") + done < <( gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --paginate \ --jq '.check_runs[] | select(.name == "Product CI") | .conclusion' ) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9f1807..105e557 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,19 +5,69 @@ 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: contents: read + pull-requests: read concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +# Jobs that only need Node/pnpm/bash use [self-hosted, evoo-ci] so macOS runners (air, mini) +# can share load. Linux-only work keeps the Linux label (Docker, xvfb, local Postgres :5433, +# Homebrew ffmpeg with libass/subtitles filter for media unit tests). + jobs: + changes: + name: Detect product changes + runs-on: [self-hosted, evoo-ci] + outputs: + product: ${{ steps.filter.outputs.product }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + fetch-depth: 0 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # 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 - runs-on: [self-hosted, evoo-ci, Linux] + needs: changes + if: needs.changes.outputs.product == 'true' + runs-on: [self-hosted, evoo-ci] steps: - uses: actions/checkout@v5 with: @@ -51,7 +101,9 @@ jobs: check: name: Typecheck - runs-on: [self-hosted, evoo-ci, Linux] + needs: changes + if: needs.changes.outputs.product == 'true' + runs-on: [self-hosted, evoo-ci] steps: - uses: actions/checkout@v5 with: @@ -88,6 +140,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 +192,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 +231,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 +268,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 +277,12 @@ jobs: product-gate: name: Product CI if: always() - needs: [lint, check, build, test, test-integration, test-e2e] - runs-on: [self-hosted, evoo-ci, Linux] + needs: [changes, lint, check, build, test, test-integration, test-e2e] + runs-on: [self-hosted, evoo-ci] 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 +291,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 +314,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..96efacf 100644 --- a/.github/workflows/publish-server-image.yml +++ b/.github/workflows/publish-server-image.yml @@ -3,9 +3,25 @@ name: publish-server-image # Release tags publish the source-addressed image used by in-app updates plus the human-readable # semver tags. Main publishes `edge`. Pull requests build with a read-only token and an isolated # cache, so untrusted code can validate the images without receiving package-publish authority. +# Self-hosted runners on one host often share ~/.docker; build/publish set DOCKER_CONFIG under +# RUNNER_TEMP so docker/login-action post-logout cannot evict a concurrent job's GHCR session. +# `ci---` handoff tags are extra aliases on the per-arch digest; GHCR does not +# expose a safe tag-only delete API (DELETE /v2/.../manifests/ is unsupported or digest-scoped), +# so they are left in place and do not affect edge/sha-* manifests assembled in publish. 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 +47,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] + 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@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # 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 +154,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 @@ -130,6 +191,12 @@ jobs: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: persist-credentials: false + - name: Prepare isolated Docker config directory + run: | + set -euo pipefail + cfg="${RUNNER_TEMP}/docker-config" + mkdir -p "$cfg" + echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV" - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 @@ -174,10 +241,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 @@ -191,6 +261,12 @@ jobs: - name: updater - name: computer steps: + - name: Prepare isolated Docker config directory + run: | + set -euo pipefail + cfg="${RUNNER_TEMP}/docker-config" + mkdir -p "$cfg" + echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV" - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 @@ -280,13 +356,3 @@ jobs: subject-name: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true - - name: Remove CI handoff tags - continue-on-error: true - env: - IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} - IMAGE_NAME: ${{ matrix.name }} - run: | - set -euo pipefail - for arch in amd64; do - docker buildx imagetools rm "${IMAGE}:ci-${GITHUB_RUN_ID}-${IMAGE_NAME}-${arch}" - done diff --git a/apps/web/src/lib/heute-brief.test.ts b/apps/web/src/lib/heute-brief.test.ts index f376f89..90c5a65 100644 --- a/apps/web/src/lib/heute-brief.test.ts +++ b/apps/web/src/lib/heute-brief.test.ts @@ -91,7 +91,8 @@ function run(overrides: Partial = {}): RunActivityRow { describe("isToday", () => { it("matches same calendar day", () => { expect(isToday("2026-09-22T08:30:00.000Z", now)).toBe(true); - expect(isToday("2026-09-21T23:59:00.000Z", now)).toBe(false); + // Midday UTC on the previous day stays the previous calendar day in UTC+14…UTC-12. + expect(isToday("2026-09-21T12:00:00.000Z", now)).toBe(false); }); }); 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..cc75541 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 ]]`); @@ -62,6 +62,14 @@ describe("server image publish workflow", () => { expect(workflowText).toContain("ci-${GITHUB_RUN_ID}-"); }); + it("isolates Docker registry auth per job and does not delete handoff tags via the registry API", () => { + expect(workflowText).toContain('echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"'); + expect(workflowText).toContain("Prepare isolated Docker config directory"); + expect(workflowText).not.toContain("imagetools rm"); + expect(workflowText).not.toContain("Delete CI handoff tags"); + expect(workflowText).toContain("safe tag-only delete API"); + }); + it("names every image under the lowercase repository namespace", () => { expect(workflowText).not.toContain(`ghcr.io/\${{ github.repository }}`); const references = @@ -75,8 +83,19 @@ 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}$/);