diff --git a/.github/workflows/update-mbx.yml b/.github/workflows/update-mbx.yml new file mode 100644 index 0000000000..6163773d37 --- /dev/null +++ b/.github/workflows/update-mbx.yml @@ -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" diff --git a/docs/development.md b/docs/development.md index 4e6295bb1a..4787ca0d93 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. @@ -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 diff --git a/flake.lock b/flake.lock index 5b2dd4da3f..e98cb73c0d 100644 --- a/flake.lock +++ b/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1789858454, - "narHash": "sha256-cKh5oV1P0XARQTgbkDur9KWxRbEDWu3f9GcPuB4YGQQ=", + "lastModified": 1792076072, + "narHash": "sha256-JZ/v4aicIKx3zbwDRPjUoVefvaaTce2NqfZfmsEIDg4=", "owner": "tignear", "repo": "nix-packages", - "rev": "2aad4b78a1800e46f844de0d0b5e063173ac5e22", + "rev": "a07b362f30e82268b006026a42c2a8f8668703c2", "type": "github" }, "original": { diff --git a/packages/playground/test/sidecar-lock.test.ts b/packages/playground/test/sidecar-lock.test.ts index 0e35125d86..002673af7e 100644 --- a/packages/playground/test/sidecar-lock.test.ts +++ b/packages/playground/test/sidecar-lock.test.ts @@ -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(); + return { ...fs, readFileSync: vi.fn(fs.readFileSync) }; +}); + const roots: string[] = []; const children = new Set(); afterEach(async () => { + vi.mocked(readFileSync).mockReset(); for (const child of children) { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); @@ -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("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); + } + }, +); diff --git a/packages/vite-plugin/src/sidecar-lock.ts b/packages/vite-plugin/src/sidecar-lock.ts index 2051b1f678..a0250d7671 100644 --- a/packages/vite-plugin/src/sidecar-lock.ts +++ b/packages/vite-plugin/src/sidecar-lock.ts @@ -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 { + 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)); + } } } @@ -103,7 +110,7 @@ export async function withSidecarLock( 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 @@ -122,7 +129,7 @@ export async function withSidecarLock( rmSync(other, { recursive: true, force: true }); continue; } - const priority = ticket(other); + const priority = await ticket(other); if ( priority === undefined || priority < number ||