ci: keep every job on internal runners without GitHub Actions storage (#192)

Runs used GitHub's artifact storage and cache service, which ran out of
the monthly included quota and blocked publishing. Caches and job
handoffs now stay on the self-hosted runner host, the desktop release
hands builds over through its draft release, and Windows waits for a
self-hosted runner instead of a GitHub-hosted one. A test enforces it.


Claude-Session: https://claude.ai/code/session_01R8L4XgqjAemFR2remB5hVY

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-10 07:44:16 +02:00 committed by GitHub
parent 634b3f823b
commit a746783c48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 224 additions and 129 deletions

View File

@ -24,7 +24,7 @@ These workflows may fail without blocking product delivery. Do **not** add them
| Check / workflow | Why infra | | Check / workflow | Why infra |
| --- | --- | | --- | --- |
| `macOS setup screenshot (infra)` (`desktop-macos-screenshot.yml`) | Native screenshot capture; skips when the runner has no display | | `macOS setup screenshot (infra)` (`desktop-macos-screenshot.yml`) | Native screenshot capture; skips when the runner has no display |
| Playwright / desktop artifact uploads | Soft-fail (`continue-on-error`) when GitHub artifact quota is exhausted | | Playwright report handoff | Soft-fail (`continue-on-error`); the report stays on the evo runner host for `publish Playwright report` |
| `publish-server-image.yml` (PR paths) | Docker image validation for infra changes | | `publish-server-image.yml` (PR paths) | Docker image validation for infra changes |
| `mobile Android screenshots` | Manual `workflow_dispatch` only | | `mobile Android screenshots` | Manual `workflow_dispatch` only |
| `nightly verification` | Scheduled visual regression | | `nightly verification` | Scheduled visual regression |

View File

@ -95,7 +95,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm lint - run: pnpm lint
@ -131,7 +130,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm db:generate - run: pnpm db:generate
env: env:
@ -171,7 +169,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm build - run: pnpm build
env: env:
@ -180,15 +177,6 @@ jobs:
ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters
- name: Electron smoke - name: Electron smoke
run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts
- name: Upload desktop setup screenshots
if: always()
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-e2e-screenshots
path: apps/desktop/e2e/screenshots
if-no-files-found: ignore
retention-days: 7
test: test:
name: Unit tests name: Unit tests
@ -222,7 +210,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm db:generate - run: pnpm db:generate
env: env:
@ -262,7 +249,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm test:integration - run: pnpm test:integration
@ -272,7 +258,7 @@ jobs:
if: needs.changes.outputs.product == 'true' if: needs.changes.outputs.product == 'true'
uses: ./.github/workflows/playwright.yml uses: ./.github/workflows/playwright.yml
with: with:
upload_artifacts: true hand_off_report: true
product-gate: product-gate:
name: Product CI name: Product CI
@ -457,7 +443,6 @@ jobs:
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
with: with:
node-version: 24 node-version: 24
cache: pnpm
- name: Set up Expo - name: Set up Expo
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true' if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9 uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9

View File

@ -50,7 +50,6 @@ jobs:
if: steps.display.outputs.available == 'true' if: steps.display.outputs.available == 'true'
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
if: steps.display.outputs.available == 'true' if: steps.display.outputs.available == 'true'
- run: pnpm --filter @rakazo/desktop build - run: pnpm --filter @rakazo/desktop build
@ -77,14 +76,3 @@ jobs:
pnpm --filter @rakazo/desktop exec playwright test pnpm --filter @rakazo/desktop exec playwright test
--config e2e/playwright.config.ts setup.spec.ts --config e2e/playwright.config.ts setup.spec.ts
--grep "first run asks whether to use a local or existing instance" --grep "first run asks whether to use a local or existing instance"
- name: Upload native macOS setup screenshot
if: always() && steps.display.outputs.available == 'true'
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-e2e-macos-screenshot
path: |
apps/desktop/e2e/screenshots/08-setup-macos-native-controls.png
apps/desktop/e2e/screenshots/09-macos-dock.png
if-no-files-found: warn
retention-days: 1

View File

@ -39,41 +39,39 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
# Runner-host caches only: never GitHub's cache or artifact storage.
- name: Restore screenshot APK - name: Restore screenshot APK
id: screenshot-apk id: screenshot-apk
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 env:
with: APK_KEY: ${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml', 'turbo.json', 'tsconfig*.json', '.github/workflows/mobile-android-screenshots.yml', 'apps/mobile/package.json', 'apps/mobile/app.json', 'apps/mobile/app.config.ts', 'apps/mobile/metro.config.js', 'apps/mobile/metro-resolver.js', 'apps/mobile/assets/**', 'apps/mobile/app/**', 'apps/mobile/components/**', 'apps/mobile/lib/**', 'apps/mobile/modules/**', 'apps/mobile/plugins/**', 'packages/chat-ui/**', 'packages/contracts/**', 'packages/core/**', 'packages/ui-tokens/**') }}
path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk run: |
key: mobile-android-apk-v2-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml', 'turbo.json', 'tsconfig*.json', '.github/workflows/mobile-android-screenshots.yml', 'apps/mobile/package.json', 'apps/mobile/app.json', 'apps/mobile/app.config.ts', 'apps/mobile/metro.config.js', 'apps/mobile/metro-resolver.js', 'apps/mobile/assets/**', 'apps/mobile/app/**', 'apps/mobile/components/**', 'apps/mobile/lib/**', 'apps/mobile/modules/**', 'apps/mobile/plugins/**', 'packages/chat-ui/**', 'packages/contracts/**', 'packages/core/**', 'packages/ui-tokens/**') }} set -euo pipefail
cache="$HOME/.cache/evoo-ci/mobile-android-apk"
echo "path=$cache/v2-$APK_KEY.apk" >> "$GITHUB_OUTPUT"
if [[ -f "$cache/v2-$APK_KEY.apk" ]]; then
mkdir -p apps/mobile/android/app/build/outputs/apk/release
cp "$cache/v2-$APK_KEY.apk" apps/mobile/android/app/build/outputs/apk/release/app-release.apk
echo "cache-hit=true" >> "$GITHUB_OUTPUT"
fi
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with: with:
node-version: 22 node-version: 22
cache: pnpm
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 17
- uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4.4.3
if: steps.screenshot-apk.outputs.cache-hit != 'true'
with:
cache-read-only: false
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Restore Maestro CLI
id: maestro-cache
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.maestro
key: maestro-${{ runner.os }}-2.6.1-3440825f
- name: Install pinned Maestro CLI - name: Install pinned Maestro CLI
if: steps.maestro-cache.outputs.cache-hit != 'true'
run: | run: |
set -euo pipefail set -euo pipefail
if [[ -f "$HOME/.maestro/.installed-2.6.1-3440825f" ]]; then
exit 0
fi
maestro_tmp=$(mktemp -d) maestro_tmp=$(mktemp -d)
trap 'rm -rf "$maestro_tmp"' EXIT trap 'rm -rf "$maestro_tmp"' EXIT
base_url=https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.6.1 base_url=https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.6.1
@ -88,8 +86,10 @@ jobs:
(cd "$maestro_tmp" && sha256sum --check --strict -) (cd "$maestro_tmp" && sha256sum --check --strict -)
(cd "$maestro_tmp" && sha256sum --check --strict checksums_sha256.txt) (cd "$maestro_tmp" && sha256sum --check --strict checksums_sha256.txt)
unzip -q "$maestro_tmp/maestro.zip" -d "$maestro_tmp" unzip -q "$maestro_tmp/maestro.zip" -d "$maestro_tmp"
rm -rf "$HOME/.maestro"
mkdir -p "$HOME/.maestro" mkdir -p "$HOME/.maestro"
cp -R "$maestro_tmp/maestro/." "$HOME/.maestro/" cp -R "$maestro_tmp/maestro/." "$HOME/.maestro/"
touch "$HOME/.maestro/.installed-2.6.1-3440825f"
- name: Configure Maestro CLI - name: Configure Maestro CLI
run: | run: |
@ -112,10 +112,14 @@ jobs:
- name: Save screenshot APK - name: Save screenshot APK
if: steps.screenshot-apk.outputs.cache-hit != 'true' if: steps.screenshot-apk.outputs.cache-hit != 'true'
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 env:
with: APK_CACHE: ${{ steps.screenshot-apk.outputs.path }}
path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk run: |
key: ${{ steps.screenshot-apk.outputs.cache-primary-key }} set -euo pipefail
mkdir -p "$(dirname "$APK_CACHE")"
find "$(dirname "$APK_CACHE")" -name '*.apk' -mtime +14 -delete
cp apps/mobile/android/app/build/outputs/apk/release/app-release.apk "$APK_CACHE.partial"
mv "$APK_CACHE.partial" "$APK_CACHE"
- name: Enable KVM - name: Enable KVM
run: | run: |
@ -151,12 +155,3 @@ jobs:
MOBILE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} MOBILE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
MOBILE_SHA: ${{ github.sha }} MOBILE_SHA: ${{ github.sha }}
run: bash scripts/publish-mobile-screenshot-gallery.sh run: bash scripts/publish-mobile-screenshot-gallery.sh
- name: Upload screenshots and diagnostics
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: mobile-android-screenshots-${{ github.run_id }}-${{ github.run_attempt }}
path: test-report/mobile-screenshots
if-no-files-found: error
retention-days: 7

View File

@ -25,8 +25,8 @@ on:
required: false required: false
type: boolean type: boolean
default: false default: false
upload_artifacts: hand_off_report:
description: Retain the report and diagnostics as GitHub artifacts description: Leave the report on the runner host for the trusted publish workflow
required: false required: false
type: boolean type: boolean
default: false default: false
@ -83,16 +83,12 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- name: Clear stale Vite dependency cache - name: Clear stale Vite dependency cache
run: rm -rf apps/web/node_modules/.vite node_modules/.vite run: rm -rf apps/web/node_modules/.vite node_modules/.vite
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Chromium - name: Install Chromium
timeout-minutes: 5 timeout-minutes: 5
# Browsers persist in ~/.cache/ms-playwright on the runner host; no GitHub cache.
# Skip --with-deps: evo Linux runners keep Chromium's OS libraries installed, # Skip --with-deps: evo Linux runners keep Chromium's OS libraries installed,
# and apt-get can stall until the job timeout on persistent self-hosted machines. # and apt-get can stall until the job timeout on persistent self-hosted machines.
run: pnpm --filter @rakazo/web exec playwright install chromium run: pnpm --filter @rakazo/web exec playwright install chromium
@ -149,7 +145,7 @@ jobs:
if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure') if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure')
run: pnpm --filter @rakazo/www e2e run: pnpm --filter @rakazo/www e2e
- name: Record Playwright result - name: Record Playwright result
if: always() && inputs.upload_artifacts if: always() && inputs.hand_off_report
env: env:
PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }} PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }}
WWW_PLAYWRIGHT_RESULT: ${{ steps.www_playwright_tests.outcome }} WWW_PLAYWRIGHT_RESULT: ${{ steps.www_playwright_tests.outcome }}
@ -162,18 +158,28 @@ jobs:
else else
printf 'failure\n' > test-report/e2e/outcome.txt printf 'failure\n' > test-report/e2e/outcome.txt
fi fi
- name: Upload Playwright artifacts - name: Hand off Playwright report on the runner host
if: always() && inputs.upload_artifacts if: always() && inputs.hand_off_report
continue-on-error: true continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 # Never GitHub artifact storage: the publish workflow runs on the same evo host
with: # and reads this archive as untrusted input.
name: playwright-artifacts-${{ github.run_id }}-${{ github.run_attempt }} env:
path: | HANDOFF_ARCHIVE: ${{ github.run_id }}-${{ github.run_attempt }}.zip
playwright-report run: |
playwright-report-www set -euo pipefail
test-report/e2e handoff="$HOME/.cache/evoo-ci/playwright-handoff"
retention-days: 1 mkdir -p "$handoff"
if-no-files-found: warn python3 - "$handoff/$HANDOFF_ARCHIVE.partial" playwright-report playwright-report-www test-report/e2e <<'PY'
import os, sys, zipfile
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as archive:
for root in sys.argv[2:]:
for directory, _, files in os.walk(root):
for name in files:
file = os.path.join(directory, name)
if os.path.isfile(file) and not os.path.islink(file):
archive.write(file)
PY
mv "$handoff/$HANDOFF_ARCHIVE.partial" "$handoff/$HANDOFF_ARCHIVE"
- name: Publish Playwright visual report - name: Publish Playwright visual report
if: always() && inputs.publish_report if: always() && inputs.publish_report
env: env:

View File

@ -37,7 +37,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- name: Read Playwright job result - name: Read Playwright job result
id: playwright id: playwright
@ -77,38 +76,31 @@ jobs:
echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}." echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}."
fi fi
echo "number=$number" >> "$GITHUB_OUTPUT" echo "number=$number" >> "$GITHUB_OUTPUT"
- name: Check for Playwright artifact - name: Take over Playwright report from the runner host
id: artifact id: artifact
env: env:
ARTIFACT_NAME: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }} HANDOFF_ARCHIVE: ${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}.zip
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }} PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }}
RUN_ID: ${{ github.event.workflow_run.id }}
shell: bash shell: bash
run: | run: |
artifacts="$(gh api --method GET \ # The Web E2E job left this archive on the same evo host; GitHub artifact storage is never used.
"repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?name=${ARTIFACT_NAME}")" handoff="$HOME/.cache/evoo-ci/playwright-handoff"
count="$(jq --arg name "$ARTIFACT_NAME" \ mkdir -p "$handoff"
'[.artifacts[] | select(.name == $name and .expired == false)] | length' \ find "$handoff" -type f -mtime +1 -delete
<<<"$artifacts")" if [[ -f "$handoff/$HANDOFF_ARCHIVE" ]]; then
if [[ "$count" == "1" ]]; then mv "$handoff/$HANDOFF_ARCHIVE" "$RUNNER_TEMP/playwright-artifacts.zip"
echo "exists=true" >> "$GITHUB_OUTPUT" echo "exists=true" >> "$GITHUB_OUTPUT"
echo "id=$(jq -r --arg name "$ARTIFACT_NAME" '.artifacts[] | select(.name == $name and .expired == false) | .id' <<<"$artifacts")" >> "$GITHUB_OUTPUT" elif [[ "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
elif [[ "$count" == "0" && "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then echo "::warning::The timed-out Playwright job ended before it handed off its report. Publishing an empty gallery."
echo "::warning::The timed-out Playwright job ended before it uploaded an artifact. Publishing an empty gallery."
echo "exists=false" >> "$GITHUB_OUTPUT" echo "exists=false" >> "$GITHUB_OUTPUT"
else else
echo "Expected exactly one unexpired Playwright artifact; found $count." >&2 echo "Missing Playwright report $handoff/$HANDOFF_ARCHIVE on this runner host." >&2
exit 1 exit 1
fi fi
- name: Download Playwright artifacts - name: Extract Playwright report
if: steps.artifact.outputs.exists == 'true' if: steps.artifact.outputs.exists == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_ID: ${{ steps.artifact.outputs.id }}
shell: bash shell: bash
run: | run: |
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/playwright-artifacts.zip"
# Keep untrusted files outside the checkout, dependencies, configuration, # Keep untrusted files outside the checkout, dependencies, configuration,
# and generated dashboard consumed by the comment step. # and generated dashboard consumed by the comment step.
python3 scripts/extract-playwright-artifacts.py \ python3 scripts/extract-playwright-artifacts.py \

View File

@ -149,6 +149,10 @@ jobs:
with: with:
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha- tags: type=sha,prefix=sha-
- name: Locate the main build cache on this runner host
id: cache
# Build caches stay on the evo host; GitHub's cache service is never used.
run: echo "dir=$HOME/.cache/evoo-ci/buildx/${{ matrix.name }}-amd64" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
builder: ${{ steps.buildx.outputs.name }} builder: ${{ steps.buildx.outputs.name }}
@ -158,8 +162,8 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }} build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }} # Pull requests only read the cache main writes.
cache-to: type=gha,mode=max,scope=pr-${{ github.event.pull_request.number }}-${{ matrix.name }} cache-from: type=local,src=${{ steps.cache.outputs.dir }}
provenance: false provenance: false
sbom: false sbom: false
@ -228,6 +232,10 @@ jobs:
# Labels only; the tagged manifest is assembled in the publish job. # Labels only; the tagged manifest is assembled in the publish job.
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha- tags: type=sha,prefix=sha-
- name: Locate the build cache on this runner host
id: cache
# Build caches stay on the evo host; GitHub's cache service is never used.
run: echo "dir=$HOME/.cache/evoo-ci/buildx/${{ matrix.name }}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT"
- id: build - id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with: with:
@ -240,10 +248,18 @@ jobs:
outputs: type=image,name=ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true
# GET /health can report the exact source commit without a deployment-supplied override. # GET /health can report the exact source commit without a deployment-supplied override.
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }} build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }} cache-from: type=local,src=${{ steps.cache.outputs.dir }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-${{ matrix.arch }} cache-to: type=local,mode=max,dest=${{ steps.cache.outputs.dir }}-new
provenance: mode=max provenance: mode=max
sbom: true sbom: true
- name: Replace the build cache
# A local cache only grows; swapping in the fresh export drops layers this build no longer uses.
env:
CACHE_DIR: ${{ steps.cache.outputs.dir }}
run: |
set -euo pipefail
rm -rf "$CACHE_DIR"
mv "$CACHE_DIR-new" "$CACHE_DIR"
- name: Tag digest for publish job handoff - name: Tag digest for publish job handoff
env: env:
DIGEST: ${{ steps.build.outputs.digest }} DIGEST: ${{ steps.build.outputs.digest }}

View File

@ -18,6 +18,8 @@ jobs:
validate: validate:
name: Validate stable release name: Validate stable release
runs-on: [self-hosted, evoo-ci, Linux] runs-on: [self-hosted, evoo-ci, Linux]
permissions:
contents: write
outputs: outputs:
tag: ${{ steps.release.outputs.tag }} tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }} version: ${{ steps.release.outputs.version }}
@ -95,17 +97,32 @@ jobs:
set -euo pipefail set -euo pipefail
include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]' include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'
if [[ "$WINDOWS_SIGNING" == "true" ]]; then if [[ "$WINDOWS_SIGNING" == "true" ]]; then
# No self-hosted Windows runner; keep GitHub-hosted only when signing credentials exist. # Internal runners only: Windows waits for a self-hosted Windows runner, never a GitHub-hosted one.
include="$(jq -c '. + [{"runner":"windows-2022","artifact":"windows"}]' <<<"$include")" include="$(jq -c '. + [{"runner":["self-hosted","evoo-ci","Windows"],"artifact":"windows"}]' <<<"$include")"
fi fi
echo "include=$include" >> "$GITHUB_OUTPUT" echo "include=$include" >> "$GITHUB_OUTPUT"
echo "windows=$WINDOWS_SIGNING" >> "$GITHUB_OUTPUT" echo "windows=$WINDOWS_SIGNING" >> "$GITHUB_OUTPUT"
- name: Open the draft release the platform builds fill
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
shell: bash
run: |
set -euo pipefail
# Builds hand their files to this draft, never to GitHub Actions artifact storage.
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
gh release delete "$RELEASE_TAG" --yes
fi
gh release create "$RELEASE_TAG" --draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
build: build:
name: Build signed ${{ matrix.artifact }} artifacts name: Build signed ${{ matrix.artifact }} artifacts
needs: validate needs: validate
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
timeout-minutes: 60 timeout-minutes: 60
permissions:
contents: write
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -244,20 +261,27 @@ jobs:
test -f apps/desktop/out/latest-linux.yml test -f apps/desktop/out/latest-linux.yml
grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml
- name: Retain installers and updater metadata - name: Hand installers and updater metadata to the draft release
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 shell: bash
with: env:
name: desktop-${{ matrix.artifact }}-${{ needs.validate.outputs.sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
path: | RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
files=(
apps/desktop/out/*.AppImage apps/desktop/out/*.AppImage
apps/desktop/out/*.blockmap apps/desktop/out/*.blockmap
apps/desktop/out/*.dmg apps/desktop/out/*.dmg
apps/desktop/out/*.exe apps/desktop/out/*.exe
apps/desktop/out/latest*.yml apps/desktop/out/latest*.yml
apps/desktop/out/*.zip apps/desktop/out/*.zip
if-no-files-found: error )
compression-level: 0 if [[ ${#files[@]} -eq 0 ]]; then
retention-days: 7 echo "The build produced no release files." >&2
exit 1
fi
bash scripts/upload-release-assets.sh "${files[@]}"
publish: publish:
name: Attest and publish complete release name: Attest and publish complete release
@ -275,11 +299,11 @@ jobs:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
ref: ${{ needs.validate.outputs.sha }} ref: ${{ needs.validate.outputs.sha }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - name: Collect every platform from the draft release
with: env:
pattern: desktop-*-${{ needs.validate.outputs.sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
path: release-artifacts RELEASE_TAG: ${{ needs.validate.outputs.tag }}
merge-multiple: true run: gh release download "$RELEASE_TAG" --dir release-artifacts
- name: Verify the complete stable update feed - name: Verify the complete stable update feed
env: env:
RELEASE_SHA: ${{ needs.validate.outputs.sha }} RELEASE_SHA: ${{ needs.validate.outputs.sha }}
@ -323,26 +347,34 @@ jobs:
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with: with:
subject-path: release-artifacts/* subject-path: release-artifacts/*
- name: Create draft and upload every platform - name: Add checksums to the draft
id: create_release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }} RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: >- run: gh release upload "$RELEASE_TAG" release-artifacts/SHA256SUMS --clobber
gh release create "$RELEASE_TAG" release-artifacts/*
--draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
- name: Publish the completed release - name: Publish the completed release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }} RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release edit "$RELEASE_TAG" --draft=false --latest run: gh release edit "$RELEASE_TAG" --draft=false --latest
- name: Remove an incomplete draft
if: failure() && steps.create_release.outcome != 'skipped' discard:
name: Remove an incomplete draft
needs: [validate, build, publish]
if: failure() && needs.validate.result == 'success'
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 5
permissions:
contents: write
steps:
- name: Delete the draft release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }} RELEASE_TAG: ${{ needs.validate.outputs.tag }}
shell: bash shell: bash
run: | run: |
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .draft 2>/dev/null)" == "true" ]]; then if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
gh release delete "$RELEASE_TAG" --yes gh release delete "$RELEASE_TAG" --yes
fi fi

View File

@ -19,7 +19,6 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm test:topology - run: pnpm test:topology
- name: Replay computer tools against real Chromium without inference - name: Replay computer tools against real Chromium without inference

View File

@ -11,6 +11,7 @@
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default. - Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
- The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push. - The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push.
- Work autonomously. Make routine calls yourself, such as opening or reopening a pull request, linking CI screenshots, or fixing gaps you found that belong to the task, then verify and report the outcome. Ask the maintainer only before irreversible actions or for product decisions without a sensible default. - Work autonomously. Make routine calls yourself, such as opening or reopening a pull request, linking CI screenshots, or fixing gaps you found that belong to the task, then verify and report the outcome. Ask the maintainer only before irreversible actions or for product decisions without a sensible default.
- CI runs only on the self-hosted `evoo-ci` runners and never consumes GitHub Actions resources: no GitHub-hosted runners, no Actions artifact storage (`upload-artifact`, `download-artifact`), and no GitHub cache service (`actions/cache`, setup-node `cache:`, buildx `type=gha`). Keep caches and job handoffs on the runner host; `infra/updater/src/github-actions-budget.test.ts` enforces this.
- After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved. - After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
- PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author. - PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author.
- Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown. - Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown.

View File

@ -66,9 +66,9 @@ describe("desktop release workflow", () => {
`include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'`, `include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'`,
); );
expect(workflow).toMatch( expect(workflow).toMatch(
/if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n(?:\s*#[^\n]*\n)*\s+include=.*\{"runner":"windows-2022","artifact":"windows"\}/, /if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n(?:\s*#[^\n]*\n)*\s+include=.*\{"runner":\["self-hosted","evoo-ci","Windows"\],"artifact":"windows"\}/,
); );
expect(workflow.split("windows-2022")).toHaveLength(2); expect(workflow).not.toMatch(/windows-\d|windows-latest/);
expect(workflow).toContain(`WINDOWS_BUILT: ${expression("needs.validate.outputs.windows")}`); expect(workflow).toContain(`WINDOWS_BUILT: ${expression("needs.validate.outputs.windows")}`);
expect(workflow).toContain('if [[ "$WINDOWS_BUILT" == "true" ]]; then'); expect(workflow).toContain('if [[ "$WINDOWS_BUILT" == "true" ]]; then');
expect(workflow).toContain("feeds+=(release-artifacts/latest.yml)"); expect(workflow).toContain("feeds+=(release-artifacts/latest.yml)");
@ -94,4 +94,11 @@ describe("desktop release workflow", () => {
expect(workflow).toContain("--draft --generate-notes"); expect(workflow).toContain("--draft --generate-notes");
expect(workflow).toContain("--draft=false --latest"); expect(workflow).toContain("--draft=false --latest");
}); });
it("hands platform builds to the draft release, never to Actions artifact storage", () => {
expect(workflow).not.toMatch(/actions\/(?:upload|download)-artifact@/);
expect(workflow).toContain("bash scripts/upload-release-assets.sh");
expect(workflow).toContain('gh release download "$RELEASE_TAG" --dir release-artifacts');
expect(workflow).toContain("needs: [validate, build, publish]");
});
}); });

View File

@ -0,0 +1,37 @@
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
// Jobs run only on the internal runners and keep their files and caches on the runner host:
// no GitHub-hosted runners, no Actions artifact storage, and no GitHub cache service.
const directory = path.resolve(import.meta.dirname, "../../../.github/workflows");
const workflows = readdirSync(directory)
.filter((name) => /\.ya?ml$/.test(name))
.map((name) => {
const text = readFileSync(path.join(directory, name), "utf8");
return {
name,
text,
jobs: (parse(text) as { jobs: Record<string, { "runs-on"?: unknown; uses?: string }> }).jobs,
};
});
describe("GitHub Actions budget", () => {
it.each(workflows)("$name runs every job on a self-hosted runner", ({ jobs, text }) => {
for (const [id, job] of Object.entries(jobs)) {
if (job.uses) continue;
const runsOn = job["runs-on"];
if (typeof runsOn === "string" && runsOn.includes("matrix.runner")) continue;
expect(runsOn, id).toEqual(expect.arrayContaining(["self-hosted", "evoo-ci"]));
}
expect(text).not.toMatch(/\b(?:ubuntu|windows|macos)-(?:\d|latest)/);
});
it.each(workflows)("$name uses no Actions artifact or cache storage", ({ text }) => {
expect(text).not.toMatch(/actions\/(?:upload-artifact|download-artifact|cache)\b/);
expect(text).not.toMatch(/^\s*cache: /m);
expect(text).not.toContain("type=gha");
expect(text).not.toContain("setup-gradle");
});
});

View File

@ -86,8 +86,9 @@ with zipfile.ZipFile(sys.argv[1], "w") as archive:
describe("Playwright artifact publication boundary", () => { describe("Playwright artifact publication boundary", () => {
it("keeps extraction and explicit report inputs outside the trusted checkout", () => { it("keeps extraction and explicit report inputs outside the trusted checkout", () => {
expect(workflow).not.toContain("actions/download-artifact@"); expect(workflow).not.toContain("actions/download-artifact@");
expect(workflow).not.toContain("/artifacts");
expect(workflow).toContain( expect(workflow).toContain(
'gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/playwright-artifacts.zip"', 'mv "$handoff/$HANDOFF_ARCHIVE" "$RUNNER_TEMP/playwright-artifacts.zip"',
); );
expect(workflow).toContain( expect(workflow).toContain(
'python3 scripts/extract-playwright-artifacts.py \\\n "$RUNNER_TEMP/playwright-artifacts.zip" "$RUNNER_TEMP/playwright-artifacts"', 'python3 scripts/extract-playwright-artifacts.py \\\n "$RUNNER_TEMP/playwright-artifacts.zip" "$RUNNER_TEMP/playwright-artifacts"',

View File

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Upload files to the draft release for RELEASE_TAG. Platform builds hand their installers to
# the draft this way, so releases never use GitHub Actions artifact storage. Needs only curl
# and jq, which every release runner has (the macOS runner has no gh).
set -euo pipefail
: "${GH_TOKEN:?GH_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
: "${RELEASE_TAG:?RELEASE_TAG is required}"
if [[ $# -eq 0 ]]; then
echo "Usage: upload-release-assets <file>..." >&2
exit 1
fi
api="${GITHUB_API_URL:-https://api.github.com}"
auth=(-H "Authorization: Bearer ${GH_TOKEN}" -H "Accept: application/vnd.github+json")
# Draft releases have no tag URL yet, so find the one draft for this tag in the list.
release="$(curl -fsSL "${auth[@]}" "$api/repos/$GITHUB_REPOSITORY/releases?per_page=100" |
jq -c --arg tag "$RELEASE_TAG" \
'[.[] | select(.draft and .tag_name == $tag)] | if length == 1 then .[0] else error("expected one draft release for \($tag)") end')"
upload_url="$(jq -r '.upload_url | sub("\\{.*\\}$"; "")' <<<"$release")"
for file in "$@"; do
name="$(basename "$file")"
# A re-run replaces its own earlier upload instead of failing on the duplicate name;
# GitHub stores spaces in asset names as dots.
existing="$(jq -r --arg name "$name" '.assets[] | select(.name == $name or .name == ($name | gsub(" "; "."))) | .id' <<<"$release")"
if [[ -n "$existing" ]]; then
curl -fsSL -X DELETE "${auth[@]}" "$api/repos/$GITHUB_REPOSITORY/releases/assets/$existing"
fi
curl -fsSL "${auth[@]}" -H "Content-Type: application/octet-stream" --data-binary "@$file" \
"$upload_url?name=$(jq -rn --arg name "$name" '$name | @uri')" >/dev/null
echo "Uploaded $name"
done