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:
parent
634b3f823b
commit
a746783c48
2
.github/MERGE_GATES.md
vendored
2
.github/MERGE_GATES.md
vendored
@ -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 |
|
||||
|
||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
12
.github/workflows/desktop-macos-screenshot.yml
vendored
12
.github/workflows/desktop-macos-screenshot.yml
vendored
@ -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
|
||||
|
||||
55
.github/workflows/mobile-android-screenshots.yml
vendored
55
.github/workflows/mobile-android-screenshots.yml
vendored
@ -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
|
||||
|
||||
44
.github/workflows/playwright.yml
vendored
44
.github/workflows/playwright.yml
vendored
@ -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:
|
||||
|
||||
32
.github/workflows/publish-playwright-report.yml
vendored
32
.github/workflows/publish-playwright-report.yml
vendored
@ -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 \
|
||||
|
||||
24
.github/workflows/publish-server-image.yml
vendored
24
.github/workflows/publish-server-image.yml
vendored
@ -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 }}
|
||||
|
||||
78
.github/workflows/release-desktop.yml
vendored
78
.github/workflows/release-desktop.yml
vendored
@ -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
|
||||
|
||||
1
.github/workflows/topology-verification.yml
vendored
1
.github/workflows/topology-verification.yml
vendored
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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]");
|
||||
});
|
||||
});
|
||||
|
||||
37
infra/updater/src/github-actions-budget.test.ts
Normal file
37
infra/updater/src/github-actions-budget.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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"',
|
||||
|
||||
36
scripts/upload-release-assets.sh
Executable file
36
scripts/upload-release-assets.sh
Executable 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
|
||||
Loading…
x
Reference in New Issue
Block a user