Skip to content
Closed
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
104 changes: 104 additions & 0 deletions .github/workflows/update-mbx.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: Update mbx

on:
schedule:
- cron: "37 5 * * *"
workflow_dispatch:
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- ".github/workflows/update-mbx.yml"
merge_group:

permissions:
contents: read

concurrency:
group: update-mbx-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: false

jobs:
update:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false

- name: Install Nix
uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31

- name: Update the mbx package input
id: update
run: |
old_version="$(nix eval --raw .#mbx.version --no-update-lock-file)"
nix flake update nix-packages
new_version="$(nix eval --raw .#mbx.version --no-update-lock-file)"
echo "old-version=$old_version" >> "$GITHUB_OUTPUT"
echo "new-version=$new_version" >> "$GITHUB_OUTPUT"
if git diff --quiet -- flake.lock; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

- name: Validate the updated package
if: steps.update.outputs.changed == 'true'
run: |
nix flake check --no-build --all-systems --no-update-lock-file
nix build --no-link --no-update-lock-file .#mbx
nix run --no-update-lock-file .#mbx -- --version

# App-authored PRs trigger the normal PR checks, unlike GITHUB_TOKEN.
- name: Create automation app token
if: steps.update.outputs.changed == 'true'
id: automation-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ vars.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write

- name: Open or update the mbx pull request
if: steps.update.outputs.changed == 'true'
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ steps.automation-token.outputs.token }}
branch: automation/update-mbx
base: ${{ github.event.repository.default_branch }}
add-paths: flake.lock
commit-message: "chore(deps): update mbx to ${{ steps.update.outputs.new-version }}"
title: "chore(deps): update mbx to ${{ steps.update.outputs.new-version }}"
body: |
Update the pinned `tignear/nix-packages` input for mbx from
`${{ steps.update.outputs.old-version }}` to `${{ steps.update.outputs.new-version }}`.

Validated the locked flake for both Linux architectures and built
and ran mbx on x86_64. PR checks also run mbx on ARM64.
Other flake inputs remain pinned. Package-definition changes may
update the lock even when the mbx version is unchanged.

validate:
name: Validate mbx (${{ matrix.runner }})
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- name: Install Nix
uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31
- name: Build and run the locked mbx package
run: |
nix build --no-link --no-update-lock-file .#mbx
expected="$(nix eval --raw --no-update-lock-file .#mbx.version)"
actual="$(nix run --no-update-lock-file .#mbx -- --version)"
test "$actual" = "mbx $expected"
12 changes: 11 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ Commit `flake.lock` together with environment changes. When updating Rust,
update the rust-overlay revision in `flake.nix` and regenerate `flake.lock`
with `nix flake update rust-overlay` if its locked revision predates the release.
Renovate groups the explicitly pinned rust-overlay revision with Rust version
updates in one PR. Other Nix inputs remain manually managed. The CI lint job
updates in one PR. The mbx input is updated by the workflow described below;
other Nix inputs remain manually managed. The CI lint job
also evaluates the locked flake for both Linux architectures and builds its
Rust toolchain check without allowing lockfile updates, so a Rust update that
is not supported by the pinned overlay fails CI before merge.
Expand All @@ -214,6 +215,15 @@ lockfile as needed; no Nix version or hash update is required. Verify with
`nix develop --command pnpm --version` (also from a workspace package directory).
mbx comes from [tignear/nix-packages](https://github.com/tignear/nix-packages).
Run `nix flake update nix-packages` to update its pinned package definition.
The `Update mbx` workflow checks that input daily at 05:37 UTC and can also
be dispatched manually. It validates the updated flake and mbx executable,
then opens or updates `automation/update-mbx` against the default branch.
Only `flake.lock` is included in the PR; other flake inputs stay pinned.
The workflow uses the existing release GitHub App credentials so its PR
triggers normal CI. PR and merge-queue checks build and run the pinned mbx
package on both x86_64 and ARM64 Linux. Updates are proposed as PRs for review;
the workflow does not merge them automatically. Upstream package-definition
updates can produce a PR even when the mbx version is unchanged.

The shell pins development tools, not the complete host OS or all build inputs.
Use the committed Cargo/pnpm lockfiles and locked dependency installation for
Expand Down
6 changes: 3 additions & 3 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 57 additions & 1 deletion packages/playground/test/sidecar-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,20 @@ import {
import { hostname, tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, expect, test } from "vitest";
import { afterEach, expect, test, vi } from "vitest";
import { generateSidecars } from "../../vite-plugin/src/sidecar.js";
import { withSidecarLock } from "../../vite-plugin/src/sidecar-lock.js";
import type { GenTsJsonOutput } from "../../vite-plugin/src/types.js";

vi.mock("node:fs", async (importOriginal) => {
const fs = await importOriginal<typeof import("node:fs")>();
return { ...fs, readFileSync: vi.fn(fs.readFileSync) };
});

const roots: string[] = [];
const children = new Set<ChildProcess>();
afterEach(async () => {
vi.mocked(readFileSync).mockReset();
for (const child of children) {
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
Expand Down Expand Up @@ -249,3 +255,53 @@ test("synchronous generation does not prevent lease renewal", async () => {
expect(statSync(lease).mtimeMs).toBeGreaterThan(before);
});
});

// Inject the Windows deletion race on every host so it is deterministic.
test.each(["recover", "waiting", "removed", "persistent", "EACCES"])(
"ticket read error: %s",
async (mode) => {
const path = root();
const { claim } = abandoned(path, false);
let reads = 0;
let entered = false;
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
vi.mocked(readFileSync).mockImplementation((...args) => {
if (String(args[0]) === join(claim, "ticket")) {
reads++;
if (
(mode === "waiting" ? reads === 2 || reads === 3 : reads <= 2) ||
mode === "persistent" ||
mode === "EACCES"
)
throw Object.assign(new Error("ticket unavailable"), {
code: mode === "EACCES" ? "EACCES" : "EPERM",
});
if (mode === "removed") rmSync(claim, { recursive: true });
}
return actual.readFileSync(...args);
});
const pending = withSidecarLock(path, () => {
entered = true;
});
if (mode === "persistent" || mode === "EACCES") {
await expect(pending).rejects.toThrow("ticket unavailable");
expect(entered).toBe(false);
expect(reads).toBe(mode === "persistent" ? 6 : 1);
} else {
try {
if (mode === "removed") {
await pending;
expect(reads).toBe(3);
} else {
await vi.waitFor(() => expect(reads).toBeGreaterThanOrEqual(4));
expect(entered).toBe(false);
rmSync(claim, { recursive: true });
}
} finally {
rmSync(claim, { recursive: true, force: true });
await pending;
}
expect(entered).toBe(true);
}
},
);
29 changes: 18 additions & 11 deletions packages/vite-plugin/src/sidecar-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,22 @@ function alive(name: string, directory: string): boolean {
}
}

function ticket(dir: string): number | undefined {
try {
const number = Number(readFileSync(join(dir, "ticket"), "utf8"));
if (!Number.isSafeInteger(number) || number < 1)
throw new Error(`Invalid declaration-generation ticket: ${dir}`);
return number;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
return undefined;
async function ticket(dir: string): Promise<number | undefined> {
for (let attempt = 0; ; attempt++) {
try {
const number = Number(readFileSync(join(dir, "ticket"), "utf8"));
if (!Number.isSafeInteger(number) || number < 1)
throw new Error(`Invalid declaration-generation ticket: ${dir}`);
return number;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") return undefined;
// Windows can report EPERM while another process deletes its ticket.
// Retry before choosing our number as well as while waiting: treating
// an unreadable ticket as absent could let us overtake its owner.
if (code !== "EPERM" || attempt >= 5) throw error;
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
}

Expand Down Expand Up @@ -103,7 +110,7 @@ export async function withSidecarLock<T>(
let largest = 0;
for (const name of claims()) {
if (alive(name, directory))
largest = Math.max(largest, ticket(join(directory, name)) ?? 0);
largest = Math.max(largest, (await ticket(join(directory, name))) ?? 0);
}
const number = largest + 1;
// Publish the completed ticket atomically. Its absence means that this
Expand All @@ -122,7 +129,7 @@ export async function withSidecarLock<T>(
rmSync(other, { recursive: true, force: true });
continue;
}
const priority = ticket(other);
const priority = await ticket(other);
if (
priority === undefined ||
priority < number ||
Expand Down
Loading