Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
12d7d5d
feat(cli): install global dependencies from release lockfiles
fengmk2 Oct 19, 2026
9ca5b28
feat(cli): use installation lockfiles for preview builds
fengmk2 Oct 19, 2026
d8b0b2a
docs(rfc): document bundled global installation lockfiles
fengmk2 Oct 19, 2026
1062ac9
fix(ci): install archive tools for Alpine lockfile tests
fengmk2 Oct 19, 2026
b2fcd9e
refactor(cli): reuse bundled Rolldown for preview lockfiles
fengmk2 Oct 19, 2026
1f19d2b
refactor(release): generate lockfiles from staged packages
fengmk2 Oct 19, 2026
10ad65f
fix(release): support retries and update installer pnpm
fengmk2 Oct 19, 2026
ee28780
fix(release): retain published version check for failed-job retries
fengmk2 Oct 19, 2026
de62e35
fix(preview): accept npm tarballs in installation lockfiles
fengmk2 Oct 20, 2026
4d39c27
test(preview): temporarily finalize locked packages for PR 3117
fengmk2 Oct 20, 2026
d27f28d
test(preview): remove temporary PR 3117 publication path
fengmk2 Oct 20, 2026
13473dc
refactor(release): simplify installation lockfile publishing
fengmk2 Oct 20, 2026
2837347
fix(install): reuse completed installs and write lockfiles atomically
fengmk2 Oct 20, 2026
fbe4bdc
fix(install): pin peer settings for frozen dependency installs
fengmk2 Oct 20, 2026
adc1bb5
fix(install): normalize frozen lockfile settings
fengmk2 Oct 20, 2026
f325845
feat(install): add explicit installation lockfile bypass
fengmk2 Oct 20, 2026
c8cdd8d
refactor(install): scope lockfile bypass environment variable to setup
fengmk2 Oct 20, 2026
6bda6fc
refactor(install): simplify lockfile reads and regression fixtures
fengmk2 Oct 20, 2026
2a23a0c
fix(install): explicitly enable frozen lockfile use
fengmk2 Oct 20, 2026
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
64 changes: 64 additions & 0 deletions .github/scripts/__tests__/publish-npm-package.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,70 @@ describe('publishNpmPackage', () => {
expect(fetchImpl.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
expect(vi.getTimerCount()).toBe(0);
});

const releasePackages = [
'@scope/native-linux',
'@scope/native-win32',
'@scope/prompts',
'@scope/core',
'vite-plus',
'@scope/global-linux',
'@scope/global-win32',
'create-vite-plus',
];
test.each(
releasePackages.flatMap((name) => [
{ name, accepted: false },
{ name, accepted: true },
]),
)('resumes publication after $name fails (accepted=$accepted)', async ({ name, accepted }) => {
const published = new Set<string>();
const uploads: string[] = [];
let interrupted = false;
async function publishRelease() {
for (const packageName of releasePackages) {
const fetchImpl: FetchLike = async () =>
response(200, {
// An accepted upload can remain hidden during npm's scanning window.
versions:
published.has(packageName) && !(accepted && packageName === name)
? { [pkg.version]: {} }
: {},
});
const runCommand: PublishCommandRunner = async () => {
uploads.push(packageName);
if (packageName === name && !interrupted) {
interrupted = true;
if (accepted) {
published.add(packageName);
}
return { exitCode: 1, output: 'connection lost' };
}
if (published.has(packageName)) {
return {
exitCode: 1,
output: 'You cannot publish over the previously published versions',
};
}
published.add(packageName);
return { exitCode: 0, output: 'published' };
};
await publishNpmPackage({
...options(fetchImpl, runCommand),
pkg: { name: packageName, version: pkg.version },
});
}
}
await expect(publishRelease()).rejects.toThrow(`Failed to publish ${name}@${pkg.version}`);
expect(published.size).toBe(releasePackages.indexOf(name) + Number(accepted));
await publishRelease();
expect(published).toEqual(new Set(releasePackages));
for (const packageName of releasePackages) {
expect(uploads.filter((upload) => upload === packageName)).toHaveLength(
packageName === name ? 2 : 1,
);
}
});
});

test('recognizes npm immutable-version errors only', () => {
Expand Down
2 changes: 2 additions & 0 deletions .github/scripts/test-install-bootstrap.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,10 @@ $originalVpShell = $env:VP_SHELL
$fixtureSha = '0123456789012345678901234567890123456789'
New-Item -ItemType Directory -Path "$testRoot/package", "$testRoot/tmp", "$testRoot/scripts" | Out-Null
Set-Content -LiteralPath "$testRoot/package/vp.exe" -Value 'Payload fixture'
Set-Content -LiteralPath "$testRoot/package/vp-install-lock.yaml" -Value 'Lockfile fixture'
@'
if ($args.Count -eq 0) {
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'vp-install-lock.yaml'))) { exit 95 }
if (Test-Path Env:VP_SELF_SETUP_SUPPORT_CHECK) { exit 99 }
New-Item -ItemType File -Path "$testRoot/binary-invoked" | Out-Null
if ($scenario -eq 'failure') { exit 42 }
Expand Down
2 changes: 2 additions & 0 deletions .github/scripts/test-install-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ LEGACY
cat > "$test_root/package/vp" <<'BINARY'
#!/bin/bash
if [ "$#" -eq 0 ]; then
test -f "$(dirname "$0")/vp-install-lock.yaml" || exit 96
test -z "${VP_SELF_SETUP_SUPPORT_CHECK+x}" || exit 99
touch "$test_root/binary-invoked"
if [ "$scenario" = failure ]; then exit 42; fi
Expand All @@ -49,6 +50,7 @@ case "$scenario" in
esac
BINARY
chmod +x "$test_root/package/vp"
printf 'release dependency tree\n' > "$test_root/package/vp-install-lock.yaml"
# Ensure the fixture digest contains + and / for the JSON escape cases.
for attempt in {1..100}; do
printf '\n# Integrity fixture %s\n' "$attempt" >> "$test_root/package/vp"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1352,7 +1352,7 @@ jobs:
-v "${{ github.workspace }}:/workspace" \
-w /workspace \
node:22-alpine3.21 sh -ec "
apk add --no-cache bash curl ca-certificates git
apk add --no-cache bash curl ca-certificates git zip unzip

# Install pnpm and re-resolve optional dependencies for musl.
# The host node_modules has glibc bindings; pnpm store holds both
Expand Down
131 changes: 118 additions & 13 deletions .github/workflows/publish-preview-register.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ name: Register preview build
# - the artifact is treated as hostile bytes, and its download pins run-id to
# the triggering run
# - no job that installs or executes preview content holds id-token
# - local packages publish first; the PR tag moves only after the global
# packages contain the lockfile generated from the published local CLI
# - `publish` of a fork PR is gated on an environment with required
# reviewers; a same-repo PR publishes without approval because the
# maintainer-applied label already binds consent to the exact built sha
Expand Down Expand Up @@ -191,6 +193,13 @@ jobs:
return;
}

if (!artifacts.some((a) => a.name === 'bridge-cli-packages' && !a.expired)) {
core.setFailed(
`run ${process.env.RUN_ID} has no active bridge-cli-packages artifact; re-apply preview-build`,
);
return;
}

const installerTargets = {
installer_x64_id: 'vp-setup-x86_64-pc-windows-msvc',
installer_arm64_id: 'vp-setup-aarch64-pc-windows-msvc',
Expand All @@ -213,12 +222,12 @@ jobs:
core.setOutput('head_repo', headRepo);
core.info(`authorized PR #${pr.number} from ${headRepo} (fork: ${isFork})`);

publish:
name: Pkg Preview
publish-local:
name: Publish preview local CLI and dependencies
needs: authorize
if: needs.authorize.outputs.eligible == 'true'
runs-on: ubuntu-latest
# Required-reviewer gate on the ONE job that mints a publish credential,
# Required-reviewer gate before either publishing job can run,
# applied to FORK PRs only. The `authorize` job proves the PR is open and
# labeled; for a fork, the gate proves a human looked at this specific run
# before a token existed, and records who. It is the only defense here that
Expand Down Expand Up @@ -262,7 +271,8 @@ jobs:
# consent), or advanced to a new head commit, in which case publishing
# this artifact would move the pr-<n> dist-tag BACKWARDS onto an older
# commit than the PR now points at.
- name: Re-check authorization before publish
- &check-preview-authorization
name: Re-check authorization before publish
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
PR_NUMBER: ${{ needs.authorize.outputs.pr }}
Expand Down Expand Up @@ -302,10 +312,9 @@ jobs:
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}

# Validates every archive against the canonical policy, rebuilds each one
# under the commit version, uploads, and registers the ref last so the
# version flips visible atomically. Authenticates with an OIDC token
# minted here; no admin-token, so no bridge secret lives in this repo.
# Publish the local CLI and dependencies under the exact commit version.
# Omit pr-url so this first phase does not move the pr-<n> tag. The bridge
# validates and rebuilds every archive before it becomes installable.
- name: Publish to the registry bridge
id: bridge
uses: voidzero-dev/pkg-pr-registry-bridge@4ca2c31c250c7137ae191bf01a8bb106d30aa106 # main
Expand All @@ -315,15 +324,111 @@ jobs:
# version derives from it, never from the artifact's own manifest.
sha: ${{ github.event.workflow_run.head_sha }}
input-dir: bridge-packages
# SR-2: derived from the API in `authorize`, never from the artifact.
# The bridge maps this to the pr-<n> dist-tag, which VP_PR_VERSION
# resolves, so an attacker-supplied value would change what installs
# for a different PR.
pr-url: ${{ needs.authorize.outputs.pr-url }}
# Must match the build workflow's pack step, which validates the same
# batch. The bridge's own WORKSPACE_PACKAGES must also allow each name.
workspace-packages: vite-plus,create-vite-plus,@voidzero-dev/vite-plus-*

prepare-global:
name: Generate preview installation lockfile
needs: [authorize, publish-local]
runs-on: ubuntu-latest
# Resolve published dependencies without any publishing credentials. Code
# comes from the default branch; the artifacts remain data, never programs.
permissions:
contents: read
actions: read
env:
VERSION: ${{ needs.publish-local.outputs.version }}
steps:
- name: Find prepared global CLI packages from an earlier attempt
id: prepared
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
...context.repo,
run_id: context.runId,
});
core.setOutput('exists', String(artifacts.some(
(artifact) => artifact.name === 'prepared-preview-cli' && !artifact.expired,
)));

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: steps.prepared.outputs.exists != 'true'
with:
persist-credentials: false
- uses: ./.github/actions/clone
if: steps.prepared.outputs.exists != 'true'
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
if: steps.prepared.outputs.exists != 'true'
- name: Install lockfile generator dependencies
if: steps.prepared.outputs.exists != 'true'
run: pnpm install --frozen-lockfile --ignore-scripts

- name: Download packed global CLI packages
if: steps.prepared.outputs.exists != 'true'
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: bridge-cli-packages
path: bridge-cli-packages
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}

- name: Wait for the published local CLI
if: steps.prepared.outputs.exists != 'true'
run: node .github/scripts/wait-for-npm-packages.ts "vite-plus@${VERSION}"
env:
PUBLISH_REGISTRY: https://registry-bridge.viteplus.dev

- name: Generate and bundle the pnpm lockfile
if: steps.prepared.outputs.exists != 'true'
run: node packages/cli/generate-preview-install-lockfile.ts bridge-cli-packages

# Save before any global package is published. Retries reuse the same
# pnpm resolution even if dependency versions change in the registry.
- name: Save prepared global CLI packages
if: steps.prepared.outputs.exists != 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: prepared-preview-cli
path: bridge-cli-packages
if-no-files-found: error
retention-days: 7
compression-level: 0

publish:
name: Publish preview global CLI and update PR tag
needs: [authorize, publish-local, prepare-global]
runs-on: ubuntu-latest
# publish-local already passed the required environment approval for forks.
# Re-check authorization below before minting this job's publishing token.
permissions:
id-token: write
actions: read
pull-requests: read
outputs:
version: ${{ steps.bridge.outputs.version }}
steps:
- *check-preview-authorization

- name: Download prepared global CLI packages
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: prepared-preview-cli
path: bridge-cli-packages
run-id: ${{ github.run_id }}
github-token: ${{ github.token }}

- name: Publish global CLI packages and update the PR tag
id: bridge
uses: voidzero-dev/pkg-pr-registry-bridge@4ca2c31c250c7137ae191bf01a8bb106d30aa106 # main
with:
mode: upload
sha: ${{ github.event.workflow_run.head_sha }}
input-dir: bridge-cli-packages
pr-url: ${{ needs.authorize.outputs.pr-url }}
workspace-packages: '@voidzero-dev/vite-plus-cli-*'

# Read the triggering run's binaries as data using code from the default
# branch. Never execute a preview binary in this write-enabled job.
comment-binary-size:
Expand Down
25 changes: 22 additions & 3 deletions .github/workflows/publish-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@ jobs:
- name: Prepare native addon and CLI binary packages
run: node ./packages/cli/publish-native-addons.ts --mode pkg-pr-new

# Pack the locally built package directories (the two preview packages
# and every platform binary) into a workflow artifact. `mode: pack` runs
# Pack the local CLI and its dependencies separately from the global
# binary packages. `mode: pack` runs
# `pnpm pack` and nothing else: no network, no credentials. The trusted
# workflow validates every archive, rewrites and re-packs it under the commit
# version, and uploads it.
Expand All @@ -196,7 +196,6 @@ jobs:
# repo and does not move when the action does.
packages: |
packages/cli/npm/*
packages/cli/cli-npm/*
packages/cli
packages/core
packages/prompts
Expand All @@ -205,11 +204,31 @@ jobs:
# validates the same batch from main.
workspace-packages: vite-plus,create-vite-plus,@voidzero-dev/vite-plus-*

- name: Pack global CLI packages for the registry bridge
uses: voidzero-dev/pkg-pr-registry-bridge@4ca2c31c250c7137ae191bf01a8bb106d30aa106 # main
with:
mode: pack
sha: ${{ github.event.pull_request.head.sha }}
output-dir: bridge-cli-packages
packages: packages/cli/cli-npm/*
workspace-packages: '@voidzero-dev/vite-plus-cli-*'

- name: Upload packed global CLI packages
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: bridge-cli-packages
path: bridge-cli-packages
overwrite: true
if-no-files-found: error
retention-days: 7
compression-level: 0

- name: Upload packed packages
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: bridge-packages
path: bridge-packages
overwrite: true
# A silently empty artifact would make the publishing workflow fail with a
# confusing "no packed tarballs" instead of failing here.
if-no-files-found: error
Expand Down
Loading
Loading