From eeec01cd0ea3649413e843b07b3697b6617cff1e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 03:17:48 +0000 Subject: [PATCH 1/9] ci: cut queue depth with path filters and smarter concurrency Skip heavy jobs for docs-only and out-of-scope paths while keeping Product CI as the branch-protection gate. Cancel superseded PR image validation runs; on main cancel stale builds but serialize publish so attest/manifest steps are never interrupted. Co-authored-by: cynarAI --- .github/workflows/ci.yml | 71 ++++++++++++++++++++-- .github/workflows/publish-server-image.yml | 69 +++++++++++++++++++-- infra/updater/src/ci-workflow.test.ts | 3 + infra/updater/src/publish-workflow.test.ts | 13 +++- 4 files changed, 144 insertions(+), 12 deletions(-) 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}$/); From c50f00374484534716aca40565284454ff97a6c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 03:29:12 +0000 Subject: [PATCH 2/9] ci: pin paths-filter to the v3.0.2 commit GitHub resolves Co-authored-by: cynarAI --- .github/workflows/publish-server-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-server-image.yml b/.github/workflows/publish-server-image.yml index 76d69a4..4da8ac5 100644 --- a/.github/workflows/publish-server-image.yml +++ b/.github/workflows/publish-server-image.yml @@ -57,7 +57,7 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: dorny/paths-filter@451258540fd6e81b87561f5d5adbd9863fc87cee # v3.0.2 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 if: github.event_name == 'push' && github.ref == 'refs/heads/main' id: filter with: From f8a5ff8e43f2ee8d589ad06aaeaac004eb5bca96 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 03:29:32 +0000 Subject: [PATCH 3/9] ci: fix paths-filter action pin on ci workflow Co-authored-by: cynarAI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40791e8..08bc2a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: dorny/paths-filter@451258540fd6e81b87561f5d5adbd9863fc87cee # v3.0.2 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter with: filters: | From de2657877803a5261b9ad2d552860187c8f62c53 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 03:32:05 +0000 Subject: [PATCH 4/9] ci: isolate GHCR docker login and delete handoff tags safely Use per-job DOCKER_CONFIG under RUNNER_TEMP so login-action logout on shared self-hosted runners cannot strip another job's credentials. Drop broken buildx imagetools rm; untag ci-* handoff labels via the registry distribution API after publish. Co-authored-by: cynarAI --- .github/workflows/publish-server-image.yml | 27 +++++++++++++++++++--- infra/updater/src/publish-workflow.test.ts | 8 +++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-server-image.yml b/.github/workflows/publish-server-image.yml index 4da8ac5..0d9d3c5 100644 --- a/.github/workflows/publish-server-image.yml +++ b/.github/workflows/publish-server-image.yml @@ -3,6 +3,10 @@ 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 registry labels on the per-arch digest; publish +# removes them via the distribution API (tag only, not the digest edge/sha tags reference). on: push: branches: [main] @@ -182,10 +186,14 @@ jobs: context: infra/sandboxes/computer - arch: amd64 runner: [self-hosted, evoo-ci, Linux] + env: + DOCKER_CONFIG: ${{ runner.temp }}/docker-config steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: persist-credentials: false + - name: Prepare isolated Docker config directory + run: mkdir -p "$DOCKER_CONFIG" - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 @@ -249,7 +257,11 @@ jobs: - name: app - name: updater - name: computer + env: + DOCKER_CONFIG: ${{ runner.temp }}/docker-config steps: + - name: Prepare isolated Docker config directory + run: mkdir -p "$DOCKER_CONFIG" - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 @@ -339,13 +351,22 @@ 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 + - name: Delete CI handoff tags env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} IMAGE_NAME: ${{ matrix.name }} run: | set -euo pipefail + repository="${IMAGE#ghcr.io/}" + token="$(curl -fsSL -u "${GITHUB_ACTOR}:${GHCR_TOKEN}" \ + "https://ghcr.io/token?service=ghcr.io&scope=repository:${repository}:pull,push" \ + | jq -er .token)" for arch in amd64; do - docker buildx imagetools rm "${IMAGE}:ci-${GITHUB_RUN_ID}-${IMAGE_NAME}-${arch}" + tag="ci-${GITHUB_RUN_ID}-${IMAGE_NAME}-${arch}" + curl -fsSL -o /dev/null -X DELETE \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.index.v1+json" \ + "https://ghcr.io/v2/${repository}/manifests/${tag}" + echo "Removed handoff tag ${tag} from ${repository}." done diff --git a/infra/updater/src/publish-workflow.test.ts b/infra/updater/src/publish-workflow.test.ts index 0c4b6b1..f7d4e80 100644 --- a/infra/updater/src/publish-workflow.test.ts +++ b/infra/updater/src/publish-workflow.test.ts @@ -62,6 +62,14 @@ describe("server image publish workflow", () => { expect(workflowText).toContain("ci-${GITHUB_RUN_ID}-"); }); + it("isolates Docker registry auth per job and deletes handoff tags without imagetools rm", () => { + expect(workflowText).toContain("DOCKER_CONFIG: ${{ runner.temp }}/docker-config"); + expect(workflowText).toContain("Prepare isolated Docker config directory"); + expect(workflowText).not.toContain("imagetools rm"); + expect(workflowText).toContain("Delete CI handoff tags"); + expect(workflowText).toContain("/v2/${repository}/manifests/${tag}"); + }); + it("names every image under the lowercase repository namespace", () => { expect(workflowText).not.toContain(`ghcr.io/\${{ github.repository }}`); const references = From 5a406b16c311df323d47551260c75e078c68b2c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 03:36:07 +0000 Subject: [PATCH 5/9] ci: grant pull-requests read for paths-filter on PRs Co-authored-by: cynarAI --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08bc2a5..7a519fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} From 58823d7187934c8df81675f223739fb46d4eb1f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 03:53:42 +0000 Subject: [PATCH 6/9] style: format publish-workflow test for biome Co-authored-by: cynarAI --- infra/updater/src/publish-workflow.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/infra/updater/src/publish-workflow.test.ts b/infra/updater/src/publish-workflow.test.ts index f7d4e80..a0fedff 100644 --- a/infra/updater/src/publish-workflow.test.ts +++ b/infra/updater/src/publish-workflow.test.ts @@ -89,9 +89,13 @@ describe("server image publish workflow", () => { 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( + "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( + "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}$/); From 28cdb33e57ad1475b86aeaaeb38c2376f1ff3a6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 04:14:26 +0000 Subject: [PATCH 7/9] ci: set DOCKER_CONFIG in step env (runner context unavailable on job env) Co-authored-by: cynarAI --- .github/workflows/publish-server-image.yml | 16 ++++++++++------ infra/updater/src/publish-workflow.test.ts | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish-server-image.yml b/.github/workflows/publish-server-image.yml index 0d9d3c5..27477ba 100644 --- a/.github/workflows/publish-server-image.yml +++ b/.github/workflows/publish-server-image.yml @@ -186,14 +186,16 @@ jobs: context: infra/sandboxes/computer - arch: amd64 runner: [self-hosted, evoo-ci, Linux] - env: - DOCKER_CONFIG: ${{ runner.temp }}/docker-config steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: persist-credentials: false - name: Prepare isolated Docker config directory - run: mkdir -p "$DOCKER_CONFIG" + 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 @@ -257,11 +259,13 @@ jobs: - name: app - name: updater - name: computer - env: - DOCKER_CONFIG: ${{ runner.temp }}/docker-config steps: - name: Prepare isolated Docker config directory - run: mkdir -p "$DOCKER_CONFIG" + 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 diff --git a/infra/updater/src/publish-workflow.test.ts b/infra/updater/src/publish-workflow.test.ts index a0fedff..9bdaea7 100644 --- a/infra/updater/src/publish-workflow.test.ts +++ b/infra/updater/src/publish-workflow.test.ts @@ -63,7 +63,7 @@ describe("server image publish workflow", () => { }); it("isolates Docker registry auth per job and deletes handoff tags without imagetools rm", () => { - expect(workflowText).toContain("DOCKER_CONFIG: ${{ runner.temp }}/docker-config"); + 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).toContain("Delete CI handoff tags"); From 629fcf6b4132a564df5103af45cd4fc06b069d09 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 04:17:36 +0000 Subject: [PATCH 8/9] ci: spread portable jobs across macOS runners; keep GHCR handoff tags Drop Linux label from gh-only and Node gate jobs so air/mini can pick them up. Replace mapfile in autopilot for macOS bash 3.2. Remove unverified GHCR tag DELETE step; document that ci-* handoff aliases are harmless. Co-authored-by: cynarAI --- .github/workflows/autopilot-auto-merge.yml | 7 ++++-- .github/workflows/ci.yml | 13 ++++++----- .github/workflows/publish-server-image.yml | 26 ++++------------------ infra/updater/src/publish-workflow.test.ts | 6 ++--- 4 files changed, 20 insertions(+), 32 deletions(-) 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 7a519fa..db204d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,10 +29,13 @@ 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). + jobs: changes: name: Detect product changes - runs-on: [self-hosted, evoo-ci, Linux] + runs-on: [self-hosted, evoo-ci] outputs: product: ${{ steps.filter.outputs.product }} steps: @@ -63,7 +66,7 @@ jobs: name: Lint needs: changes if: needs.changes.outputs.product == 'true' - runs-on: [self-hosted, evoo-ci, Linux] + runs-on: [self-hosted, evoo-ci] steps: - uses: actions/checkout@v5 with: @@ -99,7 +102,7 @@ jobs: name: Typecheck needs: changes if: needs.changes.outputs.product == 'true' - runs-on: [self-hosted, evoo-ci, Linux] + runs-on: [self-hosted, evoo-ci] steps: - uses: actions/checkout@v5 with: @@ -190,7 +193,7 @@ jobs: name: Unit tests needs: changes if: needs.changes.outputs.product == 'true' - runs-on: [self-hosted, evoo-ci, Linux] + runs-on: [self-hosted, evoo-ci] steps: - uses: actions/checkout@v5 with: @@ -274,7 +277,7 @@ jobs: name: Product CI if: always() needs: [changes, lint, check, build, test, test-integration, test-e2e] - runs-on: [self-hosted, evoo-ci, Linux] + runs-on: [self-hosted, evoo-ci] steps: - name: Verify product checks env: diff --git a/.github/workflows/publish-server-image.yml b/.github/workflows/publish-server-image.yml index 27477ba..96efacf 100644 --- a/.github/workflows/publish-server-image.yml +++ b/.github/workflows/publish-server-image.yml @@ -5,8 +5,9 @@ name: publish-server-image # 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 registry labels on the per-arch digest; publish -# removes them via the distribution API (tag only, not the digest edge/sha tags reference). +# `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] @@ -52,7 +53,7 @@ concurrency: jobs: changes: name: Detect image publish inputs - runs-on: [self-hosted, evoo-ci, Linux] + runs-on: [self-hosted, evoo-ci] outputs: publish: ${{ steps.decide.outputs.publish }} steps: @@ -355,22 +356,3 @@ jobs: subject-name: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true - - name: Delete CI handoff tags - env: - GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} - IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} - IMAGE_NAME: ${{ matrix.name }} - run: | - set -euo pipefail - repository="${IMAGE#ghcr.io/}" - token="$(curl -fsSL -u "${GITHUB_ACTOR}:${GHCR_TOKEN}" \ - "https://ghcr.io/token?service=ghcr.io&scope=repository:${repository}:pull,push" \ - | jq -er .token)" - for arch in amd64; do - tag="ci-${GITHUB_RUN_ID}-${IMAGE_NAME}-${arch}" - curl -fsSL -o /dev/null -X DELETE \ - -H "Authorization: Bearer ${token}" \ - -H "Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.index.v1+json" \ - "https://ghcr.io/v2/${repository}/manifests/${tag}" - echo "Removed handoff tag ${tag} from ${repository}." - done diff --git a/infra/updater/src/publish-workflow.test.ts b/infra/updater/src/publish-workflow.test.ts index 9bdaea7..cc75541 100644 --- a/infra/updater/src/publish-workflow.test.ts +++ b/infra/updater/src/publish-workflow.test.ts @@ -62,12 +62,12 @@ describe("server image publish workflow", () => { expect(workflowText).toContain("ci-${GITHUB_RUN_ID}-"); }); - it("isolates Docker registry auth per job and deletes handoff tags without imagetools rm", () => { + 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).toContain("Delete CI handoff tags"); - expect(workflowText).toContain("/v2/${repository}/manifests/${tag}"); + 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", () => { From 50e56a26582ba8f1ed83be96ae0eea2aa505178b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 05:30:30 +0000 Subject: [PATCH 9/9] ci: run unit tests on Linux; stabilize isToday test across time zones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert Unit tests to evoo-ci Linux for ffmpeg/libass coverage. Use a previous-day instant that stays on the prior calendar date in UTC+14…UTC-12. Co-authored-by: cynarAI --- .github/workflows/ci.yml | 5 +++-- apps/web/src/lib/heute-brief.test.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db204d9..105e557 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,8 @@ concurrency: 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). +# 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: @@ -193,7 +194,7 @@ jobs: name: Unit tests needs: changes if: needs.changes.outputs.product == 'true' - runs-on: [self-hosted, evoo-ci] + runs-on: [self-hosted, evoo-ci, Linux] steps: - uses: actions/checkout@v5 with: 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); }); });