Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/scripts/check-remote-cache-standalone.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { spawn } from 'node:child_process';
import { cp, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';

const pnpm = process.env.npm_execpath;
if (!pnpm) throw new Error('Run this check through pnpm check-remote-cache-standalone');
const directory = await mkdtemp(join(tmpdir(), 'remote-cache-standalone-'));
const excluded = new Set([
'node_modules',
'.wrangler',
'dist',
'wrangler.operator.json',
'operator-state.json',
'benchmark-results.json',
'e2e-results',
]);
try {
await cp(new URL('../../packages/remote-cache/', import.meta.url), directory, {
recursive: true,
filter(source) {
const name = basename(source);
return !excluded.has(name) && !name.startsWith('.dev.vars') && !name.startsWith('.env');
},
});
for (const args of [['install', '--frozen-lockfile'], ['check'], ['build']]) {
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [pnpm, ...args], {
cwd: directory,
stdio: 'inherit',
shell: false,
});
child.once('error', reject);
child.once('exit', (code) =>
code === 0 ? resolve() : reject(new Error(`Standalone ${args[0]} failed (${code})`)),
);
});
}
} finally {
await rm(directory, { recursive: true, force: true });
}
51 changes: 50 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ permissions:
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize]
types: [opened, synchronize, ready_for_review, converted_to_draft]
push:
branches:
- main
Expand All @@ -23,6 +23,35 @@ defaults:
shell: bash

jobs:
deploy-button-links:
name: Deploy button links
runs-on: namespace-profile-linux-x64-default
steps:
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { readFile } = require('node:fs/promises');
const main = 'https://github.com/voidzero-dev/vite-task/tree/main/packages/remote-cache';
const pr = context.payload.pull_request;
const temporary = pr?.draft
? `https://github.com/${pr.head.repo.full_name}/tree/${pr.head.ref}/packages/remote-cache`
: undefined;
for (const file of ['README.md', 'packages/remote-cache/README.md', 'packages/remote-cache/docs/self-hosting.md']) {
const content = await readFile(file, 'utf8');
const buttons = [...content.matchAll(/\]\((https:\/\/deploy\.workers\.cloudflare\.com\/\?url=[^)]+)\)/g)];
if (!buttons.length) core.setFailed(`${file}: Deploy to Cloudflare button is missing.`);
for (const [, button] of buttons) {
const source = new URL(button).searchParams.get('url');
if (source === main) continue;
if (temporary && source === temporary) {
core.warning(`${file}: Temporary PR branch URL. Restore the button to main before marking this PR ready for review.`);
} else {
core.setFailed(`${file}: Restore the Deploy to Cloudflare source URL to ${main} before merge.`);
}
}
}

detect-changes:
runs-on: namespace-profile-linux-x64-default
permissions:
Expand Down Expand Up @@ -374,16 +403,36 @@ jobs:
pnpm build-vite-task-client-types
git diff --exit-code packages/vite-task-client/src/index.d.ts

remote-cache:
needs: detect-changes
if: needs.detect-changes.outputs.code-changed == 'true'
name: Remote cache (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os:
- namespace-profile-linux-x64-default
- namespace-profile-mac-default
- namespace-profile-windows-4c-8g
runs-on: ${{ matrix.os }}
steps:
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
- uses: oxc-project/setup-node@f46a72f95efdc55273fcd042d61c84e723b2892c # v1.4.1
- run: pnpm check-remote-cache
- run: pnpm check-remote-cache-standalone

done:
runs-on: namespace-profile-linux-x64-default
if: always()
needs:
- deploy-button-links
- clippy
- test
- test-musl
- build-windows-tests
- test-windows
- fmt
- remote-cache
steps:
- run: exit 1
# Thank you, next https://github.com/vercel/next.js/blob/canary/.github/workflows/build_and_test.yml#L379
Expand Down
164 changes: 164 additions & 0 deletions .github/workflows/remote-cache-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
name: Remote cache staging

on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- packages/remote-cache/**
- .github/workflows/remote-cache-*.yml
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- vite.config.ts
push:
branches: [main]
paths:
- packages/remote-cache/**
- .github/workflows/remote-cache-*.yml
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- vite.config.ts
workflow_dispatch:

permissions:
contents: read

# All PRs and main share staging. Finish deployment, smoke tests, and notification before replacing it.
concurrency:
group: remote-cache-staging
cancel-in-progress: false

env:
REMOTE_CACHE_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
REMOTE_CACHE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
REMOTE_CACHE_RESOURCE_PREFIX: ${{ vars.REMOTE_CACHE_RESOURCE_PREFIX || 'vp-cache-ci' }}
REMOTE_CACHE_WORKERS_SUBDOMAIN: ${{ vars.REMOTE_CACHE_WORKERS_SUBDOMAIN }}
REMOTE_CACHE_PROFILE: ${{ vars.REMOTE_CACHE_PROFILE || 'free' }}

jobs:
check:
name: Check deployment source
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ env.REMOTE_CACHE_SOURCE_SHA }}
persist-credentials: false
- uses: oxc-project/setup-node@f46a72f95efdc55273fcd042d61c84e723b2892c # v1.4.1
- run: pnpm check-remote-cache
- name: Explain staging deployment availability
if: >-
always() && github.event_name == 'pull_request' &&
(github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]')
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
await core.summary.addHeading('Remote cache staging').addRaw(
'Fork and Dependabot PRs run local checks without Cloudflare credentials. ' +
'A maintainer can copy the reviewed commit to an internal branch and open a PR to deploy to staging. ' +
'No live deployment passed verification for this PR.'
).write();

deploy:
name: Deploy staging and run smoke tests
needs: check
if: >-
vars.REMOTE_CACHE_DEPLOY_ENABLED == 'true' &&
github.event.pull_request.user.login != 'dependabot[bot]' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
(github.event_name != 'workflow_dispatch' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch))
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write
pull-requests: read
outputs:
endpoint: ${{ steps.deploy.outputs.endpoint }}
steps:
- name: Check that the PR still selects this commit
if: github.event_name == 'pull_request'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: context.issue.number });
if (pr.state !== 'open' || pr.head.sha !== process.env.REMOTE_CACHE_SOURCE_SHA) {
core.setFailed('This PR deployment is obsolete.');
}
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ env.REMOTE_CACHE_SOURCE_SHA }}
persist-credentials: false
- uses: oxc-project/setup-node@f46a72f95efdc55273fcd042d61c84e723b2892c # v1.4.1
- name: Create or update persistent staging resources
id: deploy
run: pnpm --filter @voidzero-dev/remote-cache ci:deploy
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Run smoke tests against the staging Worker, D1, and R2
run: pnpm --filter @voidzero-dev/remote-cache e2e
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Save verification results and manual fixtures
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: remote-cache-e2e-${{ github.run_id }}-${{ github.run_attempt }}
path: packages/remote-cache/e2e-results/
retention-days: 7
if-no-files-found: warn

notify:
name: Update PR staging verification instructions
if: >-
always() && github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login != 'dependabot[bot]'
needs: [check, deploy]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
pull-requests: write
steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
DEPLOY_RESULT: ${{ needs.deploy.result }}
CHECK_RESULT: ${{ needs.check.result }}
DEPLOY_ENDPOINT: ${{ needs.deploy.outputs.endpoint }}
with:
script: |
// Reuse the marker so existing PR comments are updated.
const marker = '<!-- remote-cache-preview -->';
const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: context.issue.number });
if (pr.state !== 'open' || pr.head.sha !== process.env.REMOTE_CACHE_SOURCE_SHA) return;
const run = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const docs = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/blob/${pr.head.sha}/packages/remote-cache/docs/e2e-plan.md`;
const passed = process.env.DEPLOY_RESULT === 'success';
let status;
if (passed) {
const endpoint = process.env.DEPLOY_ENDPOINT;
const expected = `https://${process.env.REMOTE_CACHE_RESOURCE_PREFIX}-staging.${process.env.REMOTE_CACHE_WORKERS_SUBDOMAIN}.workers.dev/projects/manual`;
if (endpoint !== expected) throw new Error('Unexpected deployment endpoint');
status = `Cloudflare staging deployment and smoke tests passed. You can now perform manual verification.\n\n` +
`Endpoint: ${endpoint}\n\n` +
`Download the **remote-cache-e2e-${context.runId}-${process.env.GITHUB_RUN_ATTEMPT}** artifact from the [workflow run](${run}). ` +
`It contains \`manual-fetch.cbor\`, \`manual-manifest.json\`, and \`report.json\`.\n\n` +
`PR checks cover public reads and rejected writes. Main-branch push checks also cover authorized HTTP stores. ` +
`This staging endpoint is shared by all PRs and main. A later deployment replaces its code and manual fixture. ` +
`Compare the deployment ID in the response with the artifact before manual verification. Closing this PR does not remove staging.`;
} else if (process.env.CHECK_RESULT !== 'success') {
status = `The source checks did not pass. No verified Cloudflare staging deployment is ready. See the [workflow run](${run}).`;
} else if (process.env.DEPLOY_RESULT === 'skipped') {
status = `Cloudflare deployment is disabled. Configure the repository secrets and variables described in the [e2e plan](${docs}), ` +
`then set \`REMOTE_CACHE_DEPLOY_ENABLED=true\` and rerun this workflow. No live deployment passed verification.`;
} else {
status = `Cloudflare deployment or e2e verification failed. The staging deployment is not marked ready. See the [workflow run](${run}).`;
}
const body = `${marker}\n### Remote cache staging\n\nCommit: \`${pr.head.sha}\`\n\n${status}\n\n[Manual checks and complete e2e plan](${docs}).`;
const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number: pr.number, per_page: 100 });
const previous = comments.find(c => c.user?.login === 'github-actions[bot]' && c.body?.includes(marker));
if (previous) await github.rest.issues.updateComment({ ...context.repo, comment_id: previous.id, body });
else await github.rest.issues.createComment({ ...context.repo, issue_number: pr.number, body });
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Changelog

- **Added** A self-hosted public remote cache service with a Deploy to Cloudflare button, GitHub Actions write authorization, storage limits, and automatic cleanup ([#718](https://github.com/voidzero-dev/vite-task/pull/718)).
- **Fixed** `vp run` no longer hangs or fails when a task leaves a process running behind it, such as a dev server or a background helper, or when one of a task's processes is killed. The run finishes as soon as the task itself does, and the files the task used are still recorded ([#544](https://github.com/voidzero-dev/vite-task/issues/544), [#675](https://github.com/voidzero-dev/vite-task/pull/675)).
- **Fixed** A task that reads or writes an unusually large number of files now runs to the end instead of being killed partway through. Vite+ reports the run as not cached, because it could not record every file the task used ([#533](https://github.com/voidzero-dev/vite-task/issues/533), [#675](https://github.com/voidzero-dev/vite-task/pull/675)).
- **Fixed** Vite+ diagnostics now display individual paths and working directories without Rust debug formatting such as quoted paths or escaped Windows backslashes ([#534](https://github.com/voidzero-dev/vite-task/pull/534)).
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ vp run -t @my/app#build # run in a package and its transitive dependencies
vp run --cache build # run with caching enabled
```

## Remote cache service

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2Fvoidzero-dev%2Fvite-task%2Ftree%2Ffeat%2Fpublic-remote-cache%2Fpackages%2Fremote-cache)

To deploy the public remote cache service in your own Cloudflare account and bind it to a GitHub repository, follow the [self-hosting guide](packages/remote-cache/docs/self-hosting.md). The service is available in this source tree; the `vp run` remote-cache client adapter is not yet implemented here.

## Sponsors

Thanks to [namespace.so](https://namespace.so) for powering our CI/CD pipelines with fast, free macOS and Linux runners.
Expand Down
3 changes: 3 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ watch-check:
test:
cargo test

remote-cache:
pnpm check-remote-cache

lint:
cargo clippy --workspace --all-targets --all-features -- --deny warnings

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"prepare": "vp config",
"build-vite-task-client-types": "tsc -p packages/vite-task-client/tsconfig.json"
"build-vite-task-client-types": "tsc -p packages/vite-task-client/tsconfig.json",
"check-remote-cache": "pnpm --filter @voidzero-dev/remote-cache check && pnpm --filter @voidzero-dev/remote-cache smoke",
"check-remote-cache-standalone": "node .github/scripts/check-remote-cache-standalone.mjs"
},
"devDependencies": {
"@tsconfig/strictest": "catalog:",
Expand Down
9 changes: 9 additions & 0 deletions packages/remote-cache/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.wrangler/
node_modules/
dist/
.env*
.dev.vars*
operator-state.json
wrangler.operator.json
benchmark-results.json
e2e-results/
21 changes: 21 additions & 0 deletions packages/remote-cache/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026-present, VoidZero Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading