diff --git a/.github/MERGE_GATES.md b/.github/MERGE_GATES.md index 6e84ff9..9d25d35 100644 --- a/.github/MERGE_GATES.md +++ b/.github/MERGE_GATES.md @@ -24,7 +24,7 @@ These workflows may fail without blocking product delivery. Do **not** add them | Check / workflow | Why infra | | --- | --- | | `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 | | `mobile Android screenshots` | Manual `workflow_dispatch` only | | `nightly verification` | Scheduled visual regression | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 105e557..46952c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint @@ -131,7 +130,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm db:generate env: @@ -171,7 +169,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build env: @@ -180,15 +177,6 @@ jobs: ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters - name: Electron smoke 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: name: Unit tests @@ -222,7 +210,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm db:generate env: @@ -262,7 +249,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test:integration @@ -272,7 +258,7 @@ jobs: if: needs.changes.outputs.product == 'true' uses: ./.github/workflows/playwright.yml with: - upload_artifacts: true + hand_off_report: true product-gate: name: Product CI @@ -457,7 +443,6 @@ jobs: if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true' with: node-version: 24 - cache: pnpm - name: Set up Expo if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true' uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9 diff --git a/.github/workflows/desktop-macos-screenshot.yml b/.github/workflows/desktop-macos-screenshot.yml index 11ba678..1ff60c4 100644 --- a/.github/workflows/desktop-macos-screenshot.yml +++ b/.github/workflows/desktop-macos-screenshot.yml @@ -50,7 +50,6 @@ jobs: if: steps.display.outputs.available == 'true' with: node-version: 22 - cache: pnpm - run: pnpm install --frozen-lockfile if: steps.display.outputs.available == 'true' - run: pnpm --filter @rakazo/desktop build @@ -77,14 +76,3 @@ jobs: pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts setup.spec.ts --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 diff --git a/.github/workflows/mobile-android-screenshots.yml b/.github/workflows/mobile-android-screenshots.yml index 5642039..e1c0593 100644 --- a/.github/workflows/mobile-android-screenshots.yml +++ b/.github/workflows/mobile-android-screenshots.yml @@ -39,41 +39,39 @@ jobs: with: persist-credentials: false + # Runner-host caches only: never GitHub's cache or artifact storage. - name: Restore screenshot APK id: screenshot-apk - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 - with: - path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk - 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/**') }} + env: + 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/**') }} + run: | + 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: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 - cache: pnpm - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: distribution: temurin 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 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 - if: steps.maestro-cache.outputs.cache-hit != 'true' run: | set -euo pipefail + if [[ -f "$HOME/.maestro/.installed-2.6.1-3440825f" ]]; then + exit 0 + fi maestro_tmp=$(mktemp -d) trap 'rm -rf "$maestro_tmp"' EXIT 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 checksums_sha256.txt) unzip -q "$maestro_tmp/maestro.zip" -d "$maestro_tmp" + rm -rf "$HOME/.maestro" mkdir -p "$HOME/.maestro" cp -R "$maestro_tmp/maestro/." "$HOME/.maestro/" + touch "$HOME/.maestro/.installed-2.6.1-3440825f" - name: Configure Maestro CLI run: | @@ -112,10 +112,14 @@ jobs: - name: Save screenshot APK if: steps.screenshot-apk.outputs.cache-hit != 'true' - uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 - with: - path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk - key: ${{ steps.screenshot-apk.outputs.cache-primary-key }} + env: + APK_CACHE: ${{ steps.screenshot-apk.outputs.path }} + run: | + 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 run: | @@ -151,12 +155,3 @@ jobs: MOBILE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} MOBILE_SHA: ${{ github.sha }} 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 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 7ea5a74..e295589 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -25,8 +25,8 @@ on: required: false type: boolean default: false - upload_artifacts: - description: Retain the report and diagnostics as GitHub artifacts + hand_off_report: + description: Leave the report on the runner host for the trusted publish workflow required: false type: boolean default: false @@ -83,16 +83,12 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - cache: pnpm - run: pnpm install --frozen-lockfile - name: Clear stale Vite dependency cache 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 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, # and apt-get can stall until the job timeout on persistent self-hosted machines. 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') run: pnpm --filter @rakazo/www e2e - name: Record Playwright result - if: always() && inputs.upload_artifacts + if: always() && inputs.hand_off_report env: PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }} WWW_PLAYWRIGHT_RESULT: ${{ steps.www_playwright_tests.outcome }} @@ -162,18 +158,28 @@ jobs: else printf 'failure\n' > test-report/e2e/outcome.txt fi - - name: Upload Playwright artifacts - if: always() && inputs.upload_artifacts + - name: Hand off Playwright report on the runner host + if: always() && inputs.hand_off_report continue-on-error: true - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: playwright-artifacts-${{ github.run_id }}-${{ github.run_attempt }} - path: | - playwright-report - playwright-report-www - test-report/e2e - retention-days: 1 - if-no-files-found: warn + # Never GitHub artifact storage: the publish workflow runs on the same evo host + # and reads this archive as untrusted input. + env: + HANDOFF_ARCHIVE: ${{ github.run_id }}-${{ github.run_attempt }}.zip + run: | + set -euo pipefail + handoff="$HOME/.cache/evoo-ci/playwright-handoff" + mkdir -p "$handoff" + 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 if: always() && inputs.publish_report env: diff --git a/.github/workflows/publish-playwright-report.yml b/.github/workflows/publish-playwright-report.yml index 95b0aeb..3da0426 100644 --- a/.github/workflows/publish-playwright-report.yml +++ b/.github/workflows/publish-playwright-report.yml @@ -37,7 +37,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - cache: pnpm - run: pnpm install --frozen-lockfile - name: Read Playwright job result id: playwright @@ -77,38 +76,31 @@ jobs: echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}." fi echo "number=$number" >> "$GITHUB_OUTPUT" - - name: Check for Playwright artifact + - name: Take over Playwright report from the runner host id: artifact env: - ARTIFACT_NAME: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HANDOFF_ARCHIVE: ${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}.zip PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }} - RUN_ID: ${{ github.event.workflow_run.id }} shell: bash run: | - artifacts="$(gh api --method GET \ - "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?name=${ARTIFACT_NAME}")" - count="$(jq --arg name "$ARTIFACT_NAME" \ - '[.artifacts[] | select(.name == $name and .expired == false)] | length' \ - <<<"$artifacts")" - if [[ "$count" == "1" ]]; then + # The Web E2E job left this archive on the same evo host; GitHub artifact storage is never used. + handoff="$HOME/.cache/evoo-ci/playwright-handoff" + mkdir -p "$handoff" + find "$handoff" -type f -mtime +1 -delete + if [[ -f "$handoff/$HANDOFF_ARCHIVE" ]]; then + mv "$handoff/$HANDOFF_ARCHIVE" "$RUNNER_TEMP/playwright-artifacts.zip" 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 [[ "$count" == "0" && "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then - echo "::warning::The timed-out Playwright job ended before it uploaded an artifact. Publishing an empty gallery." + elif [[ "$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 "exists=false" >> "$GITHUB_OUTPUT" 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 fi - - name: Download Playwright artifacts + - name: Extract Playwright report if: steps.artifact.outputs.exists == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARTIFACT_ID: ${{ steps.artifact.outputs.id }} shell: bash run: | - gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/playwright-artifacts.zip" # Keep untrusted files outside the checkout, dependencies, configuration, # and generated dashboard consumed by the comment step. python3 scripts/extract-playwright-artifacts.py \ diff --git a/.github/workflows/publish-server-image.yml b/.github/workflows/publish-server-image.yml index bded3e7..e234998 100644 --- a/.github/workflows/publish-server-image.yml +++ b/.github/workflows/publish-server-image.yml @@ -149,6 +149,10 @@ jobs: with: images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} 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 with: builder: ${{ steps.buildx.outputs.name }} @@ -158,8 +162,8 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }} - cache-from: type=gha,scope=${{ matrix.name }} - cache-to: type=gha,mode=max,scope=pr-${{ github.event.pull_request.number }}-${{ matrix.name }} + # Pull requests only read the cache main writes. + cache-from: type=local,src=${{ steps.cache.outputs.dir }} provenance: false sbom: false @@ -228,6 +232,10 @@ jobs: # Labels only; the tagged manifest is assembled in the publish job. images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }} 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 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 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 # GET /health can report the exact source commit without a deployment-supplied override. build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }} - cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.name }}-${{ matrix.arch }} + cache-from: type=local,src=${{ steps.cache.outputs.dir }} + cache-to: type=local,mode=max,dest=${{ steps.cache.outputs.dir }}-new provenance: mode=max 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 env: DIGEST: ${{ steps.build.outputs.digest }} diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index ff30a4e..1edbfaa 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -18,6 +18,8 @@ jobs: validate: name: Validate stable release runs-on: [self-hosted, evoo-ci, Linux] + permissions: + contents: write outputs: tag: ${{ steps.release.outputs.tag }} version: ${{ steps.release.outputs.version }} @@ -95,17 +97,32 @@ jobs: set -euo pipefail include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]' if [[ "$WINDOWS_SIGNING" == "true" ]]; then - # No self-hosted Windows runner; keep GitHub-hosted only when signing credentials exist. - include="$(jq -c '. + [{"runner":"windows-2022","artifact":"windows"}]' <<<"$include")" + # Internal runners only: Windows waits for a self-hosted Windows runner, never a GitHub-hosted one. + include="$(jq -c '. + [{"runner":["self-hosted","evoo-ci","Windows"],"artifact":"windows"}]' <<<"$include")" fi echo "include=$include" >> "$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: name: Build signed ${{ matrix.artifact }} artifacts needs: validate runs-on: ${{ matrix.runner }} timeout-minutes: 60 + permissions: + contents: write strategy: fail-fast: false matrix: @@ -244,20 +261,27 @@ jobs: test -f apps/desktop/out/latest-linux.yml grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml - - name: Retain installers and updater metadata - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: desktop-${{ matrix.artifact }}-${{ needs.validate.outputs.sha }} - path: | + - name: Hand installers and updater metadata to the draft release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + run: | + set -euo pipefail + shopt -s nullglob + files=( apps/desktop/out/*.AppImage apps/desktop/out/*.blockmap apps/desktop/out/*.dmg apps/desktop/out/*.exe apps/desktop/out/latest*.yml apps/desktop/out/*.zip - if-no-files-found: error - compression-level: 0 - retention-days: 7 + ) + if [[ ${#files[@]} -eq 0 ]]; then + echo "The build produced no release files." >&2 + exit 1 + fi + bash scripts/upload-release-assets.sh "${files[@]}" publish: name: Attest and publish complete release @@ -275,11 +299,11 @@ jobs: fetch-depth: 0 persist-credentials: false ref: ${{ needs.validate.outputs.sha }} - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - pattern: desktop-*-${{ needs.validate.outputs.sha }} - path: release-artifacts - merge-multiple: true + - name: Collect every platform from the draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + run: gh release download "$RELEASE_TAG" --dir release-artifacts - name: Verify the complete stable update feed env: RELEASE_SHA: ${{ needs.validate.outputs.sha }} @@ -323,26 +347,34 @@ jobs: uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 with: subject-path: release-artifacts/* - - name: Create draft and upload every platform - id: create_release + - name: Add checksums to the draft env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ needs.validate.outputs.tag }} - run: >- - gh release create "$RELEASE_TAG" release-artifacts/* - --draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag + run: gh release upload "$RELEASE_TAG" release-artifacts/SHA256SUMS --clobber - name: Publish the completed release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ needs.validate.outputs.tag }} 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: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ needs.validate.outputs.tag }} shell: bash 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 fi diff --git a/.github/workflows/topology-verification.yml b/.github/workflows/topology-verification.yml index 697ed25..b1b6ccd 100644 --- a/.github/workflows/topology-verification.yml +++ b/.github/workflows/topology-verification.yml @@ -19,7 +19,6 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test:topology - name: Replay computer tools against real Chromium without inference diff --git a/AGENTS.md b/AGENTS.md index 58df55b..1b95dd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ - 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. - 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. - 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. diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 6863817..d6828d5 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -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"}]'`, ); 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('if [[ "$WINDOWS_BUILT" == "true" ]]; then'); 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=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]"); + }); }); diff --git a/infra/updater/src/github-actions-budget.test.ts b/infra/updater/src/github-actions-budget.test.ts new file mode 100644 index 0000000..06715ee --- /dev/null +++ b/infra/updater/src/github-actions-budget.test.ts @@ -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 }).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"); + }); +}); diff --git a/packages/testkit/src/playwright-artifact-publication.test.ts b/packages/testkit/src/playwright-artifact-publication.test.ts index b396b65..b299c1e 100644 --- a/packages/testkit/src/playwright-artifact-publication.test.ts +++ b/packages/testkit/src/playwright-artifact-publication.test.ts @@ -86,8 +86,9 @@ with zipfile.ZipFile(sys.argv[1], "w") as archive: describe("Playwright artifact publication boundary", () => { it("keeps extraction and explicit report inputs outside the trusted checkout", () => { expect(workflow).not.toContain("actions/download-artifact@"); + expect(workflow).not.toContain("/artifacts"); 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( 'python3 scripts/extract-playwright-artifacts.py \\\n "$RUNNER_TEMP/playwright-artifacts.zip" "$RUNNER_TEMP/playwright-artifacts"', diff --git a/scripts/upload-release-assets.sh b/scripts/upload-release-assets.sh new file mode 100755 index 0000000..ead3de7 --- /dev/null +++ b/scripts/upload-release-assets.sh @@ -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 ..." >&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