diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 05a0e62112..977c70657f 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -13,6 +13,14 @@ # (npm_tag becomes `rc` if version contains `-rc.`, or # `next` if `latest=false`) # +# Selective preview builds: the `targets` input (all | rivetkit | container-runner +# | engine | cli) narrows a preview to one target family so unrelated builds are +# skipped. The `context` job resolves it into `build_targets` / `build_wasm` / +# `build_docker` outputs that gate the matrix and jobs. Skipped npm families have +# their inbound dependency (rivetkit-family → @rivetkit/engine-cli) repinned to the +# latest published version so previews still install. Releases always build every +# target; a partial `targets` on a release is rejected. +# # See ~/.agents/specs/publish-flow-unification.md for the design. # ============================================================================ @@ -30,6 +38,17 @@ on: required: true type: boolean default: true + targets: + description: "Preview only: which targets to build/publish. Releases always build everything." + required: false + type: choice + default: all + options: + - all + - rivetkit + - container-runner + - engine + - cli env: # Depot project ID (rivet-dev/rivet-engine). @@ -48,6 +67,10 @@ jobs: npm_tag: ${{ steps.ctx.outputs.npm_tag }} sha: ${{ steps.ctx.outputs.sha }} latest: ${{ steps.ctx.outputs.latest }} + targets: ${{ steps.ctx.outputs.targets }} + build_targets: ${{ steps.ctx.outputs.build_targets }} + build_wasm: ${{ steps.ctx.outputs.build_wasm }} + build_docker: ${{ steps.ctx.outputs.build_docker }} steps: - uses: actions/checkout@v4 with: @@ -218,14 +241,20 @@ jobs: id-token: write # required for depot OIDC trust packages: read steps: - - name: Skip release-only targets on non-release triggers + - name: Skip release-only and out-of-scope targets id: gate run: | + skip=false + # Release-only targets (e.g. Windows) only build on release triggers. if [ "${{ matrix.release_only }}" = "true" ] && [ "${{ needs.context.outputs.trigger }}" != "release" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - else - echo "skip=false" >> $GITHUB_OUTPUT + skip=true fi + # Selective preview: skip build targets not in the resolved scope. + case " ${{ needs.context.outputs.build_targets }} " in + *" ${{ matrix.build_target }} "*) : ;; + *) skip=true ;; + esac + echo "skip=$skip" >> $GITHUB_OUTPUT - uses: actions/checkout@v4 if: steps.gate.outputs.skip != 'true' with: @@ -276,6 +305,7 @@ jobs: # --------------------------------------------------------------------------- build-wasm: needs: [context] + if: needs.context.outputs.build_wasm == 'true' name: "Build rivetkit-wasm" runs-on: depot-ubuntu-24.04-8 permissions: @@ -314,6 +344,7 @@ jobs: # --------------------------------------------------------------------------- docker-images: needs: [context] + if: needs.context.outputs.build_docker == 'true' name: "Docker ${{ matrix.arch_suffix }}" strategy: fail-fast: false @@ -357,6 +388,8 @@ jobs: build-args: | BUILD_FRONTEND=${{ steps.mode.outputs.build_frontend }} CARGO_BUILD_MODE=${{ steps.mode.outputs.cargo_build_mode }} + secrets: | + DEPOT_TOKEN=${{ secrets.DEPOT_TOKEN }} - name: Build & Push (rivetdev/engine:slim) uses: docker/build-push-action@v4 with: @@ -369,6 +402,8 @@ jobs: build-args: | BUILD_FRONTEND=${{ steps.mode.outputs.build_frontend }} CARGO_BUILD_MODE=${{ steps.mode.outputs.cargo_build_mode }} + secrets: | + DEPOT_TOKEN=${{ secrets.DEPOT_TOKEN }} # --------------------------------------------------------------------------- # publish — npm publish + R2 upload + Docker manifest + release tail @@ -376,11 +411,13 @@ jobs: publish: needs: [context, build, build-wasm, docker-images] name: "Publish" + # build-wasm / docker-images are skipped (not 'success') when their target + # is out of scope for a selective preview; treat skipped as acceptable. if: | !cancelled() && needs.build.result == 'success' && - needs.build-wasm.result == 'success' && - needs.docker-images.result == 'success' + (needs.build-wasm.result == 'success' || needs.build-wasm.result == 'skipped') && + (needs.docker-images.result == 'success' || needs.docker-images.result == 'skipped') # npm trusted publishing currently requires a GitHub-hosted runner. runs-on: ubuntu-24.04 permissions: @@ -409,35 +446,43 @@ jobs: # ---- download + place native + engine artifacts ---- - name: Download native artifacts + if: contains(needs.context.outputs.targets, 'rivetkit') uses: actions/download-artifact@v4 with: path: native-artifacts pattern: native-* merge-multiple: true + # Engine binaries are also needed by the `cli` group (bundled into CLI + # platform packages), so key this off build_targets, not the group. - name: Download engine artifacts + if: contains(needs.context.outputs.build_targets, 'engine') uses: actions/download-artifact@v4 with: path: engine-artifacts pattern: engine-* merge-multiple: true - name: Download CLI artifacts + if: contains(needs.context.outputs.targets, 'cli') uses: actions/download-artifact@v4 with: path: cli-artifacts pattern: cli-* merge-multiple: true - name: Download container runner artifacts + if: contains(needs.context.outputs.targets, 'container-runner') uses: actions/download-artifact@v4 with: path: container-runner-artifacts pattern: container-runner-* merge-multiple: true - name: Download wasm package artifact + if: needs.context.outputs.build_wasm == 'true' uses: actions/download-artifact@v4 with: name: wasm-package path: rivetkit-typescript/packages/rivetkit-wasm/pkg - name: Place native binaries in platform packages + if: contains(needs.context.outputs.targets, 'rivetkit') run: | NATIVE_DIR=rivetkit-typescript/packages/rivetkit-napi for f in native-artifacts/*.node; do @@ -556,6 +601,7 @@ jobs: # ---- build TypeScript packages (turbo dep graph picks up native) ---- - name: Build TypeScript packages + if: contains(needs.context.outputs.targets, 'rivetkit') env: SKIP_WASM_BUILD: "1" run: | @@ -581,6 +627,7 @@ jobs: ${{ needs.context.outputs.trigger == 'release' && '--release-mode' || '' }} - name: Generate engine checksum manifest + if: contains(needs.context.outputs.targets, 'engine') run: | ( cd engine-artifacts @@ -588,12 +635,14 @@ jobs: ) - name: Upload engine binaries to R2 + if: contains(needs.context.outputs.targets, 'engine') env: R2_RELEASES_ACCESS_KEY_ID: ${{ secrets.R2_RELEASES_ACCESS_KEY_ID }} R2_RELEASES_SECRET_ACCESS_KEY: ${{ secrets.R2_RELEASES_SECRET_ACCESS_KEY }} run: pnpm --filter=publish exec tsx src/ci/bin.ts upload-r2 --source "$GITHUB_WORKSPACE/engine-artifacts" --sha ${{ needs.context.outputs.sha }} - name: Upload container runner binaries to R2 + if: contains(needs.context.outputs.targets, 'container-runner') env: R2_RELEASES_ACCESS_KEY_ID: ${{ secrets.R2_RELEASES_ACCESS_KEY_ID }} R2_RELEASES_SECRET_ACCESS_KEY: ${{ secrets.R2_RELEASES_SECRET_ACCESS_KEY }} @@ -636,6 +685,7 @@ jobs: --allow-dirty - name: Create Docker multi-arch manifests + if: needs.context.outputs.build_docker == 'true' run: pnpm --filter=publish exec tsx src/ci/bin.ts docker-manifest --sha ${{ needs.context.outputs.sha }} # ---- release-only tail ---- diff --git a/docker/engine/Dockerfile b/docker/engine/Dockerfile index 092279f74d..4dbdcd755a 100644 --- a/docker/engine/Dockerfile +++ b/docker/engine/Dockerfile @@ -14,6 +14,23 @@ ARG VITE_APP_API_URL=__SAME__ ARG VITE_APP_TURNSTILE_SITE_KEY= ARG OVERRIDE_GIT_SHA ARG RUST_TOOLCHAIN=1.91.1 +ARG SCCACHE_VERSION=v0.8.2 + +# Install sccache so the engine compile shares the Depot WebDAV cache with the +# matrix builds (docker/build/*.Dockerfile). The engine base image does not +# ship it, unlike the builder-base-linux-* images. Installed before COPY so the +# layer stays cached across source changes. Match the binary to the native +# build arch (each docker-images matrix leg runs on its own arch runner). +RUN if [ "$TARGETARCH" = "arm64" ]; then SCCACHE_ARCH=aarch64; else SCCACHE_ARCH=x86_64; fi && \ + wget -q "https://github.com/mozilla/sccache/releases/download/${SCCACHE_VERSION}/sccache-${SCCACHE_VERSION}-${SCCACHE_ARCH}-unknown-linux-musl.tar.gz" && \ + tar -xzf "sccache-${SCCACHE_VERSION}-${SCCACHE_ARCH}-unknown-linux-musl.tar.gz" && \ + mv "sccache-${SCCACHE_VERSION}-${SCCACHE_ARCH}-unknown-linux-musl/sccache" /usr/local/bin/sccache && \ + chmod +x /usr/local/bin/sccache && \ + rm -rf "sccache-${SCCACHE_VERSION}-${SCCACHE_ARCH}-unknown-linux-musl"* + +ENV RUSTC_WRAPPER=sccache \ + SCCACHE_WEBDAV_ENDPOINT=https://cache.depot.dev \ + SCCACHE_IDLE_TIMEOUT=0 WORKDIR /app @@ -40,19 +57,30 @@ RUN if [ "$BUILD_FRONTEND" = "true" ]; then \ # included in the output because of cache mount) RUN \ --mount=type=secret,id=netrc,target=/root/.netrc,mode=0600 \ + --mount=type=secret,id=DEPOT_TOKEN,env=SCCACHE_WEBDAV_TOKEN \ --mount=type=cache,target=/usr/local/cargo/git,id=univseral-cargo-git \ --mount=type=cache,target=/usr/local/cargo/registry,id=univseral-cargo-registry \ --mount=type=cache,target=/app/target,id=univseral-target \ --mount=type=cache,target=/root/.cache,id=universal-user-cache \ + set -e && \ + if [ -z "${SCCACHE_WEBDAV_TOKEN:-}" ]; then \ + echo "[sccache] no DEPOT_TOKEN, disabling"; unset RUSTC_WRAPPER; \ + elif ! (sccache --start-server 2>/tmp/sccache-start.err && sccache --show-stats >/dev/null 2>&1); then \ + echo "[sccache] backend health check failed, disabling:"; cat /tmp/sccache-start.err 2>/dev/null || true; \ + sccache --stop-server >/dev/null 2>&1 || true; \ + unset RUSTC_WRAPPER SCCACHE_WEBDAV_ENDPOINT SCCACHE_WEBDAV_TOKEN; \ + else \ + echo "[sccache] enabled via ${SCCACHE_WEBDAV_ENDPOINT}"; \ + fi && \ if [ "$CARGO_BUILD_MODE" = "release" ]; then \ RUSTFLAGS="--cfg tokio_unstable" cargo build -p rivet-engine --bin rivet-engine --release; \ else \ RUSTFLAGS="--cfg tokio_unstable" cargo build -p rivet-engine --bin rivet-engine; \ fi && \ - # cargo install --locked tokio-console && \ mkdir /app/dist/ && \ echo "Copying binary" && \ - cp target/$CARGO_BUILD_MODE/rivet-engine /app/dist/ + cp target/$CARGO_BUILD_MODE/rivet-engine /app/dist/ && \ + (sccache --show-stats 2>/dev/null || true) # MARK: Engine (full, base) FROM ghcr.io/rivet-dev/rivet/engine-base-runtime-full:a36b881 AS engine-full-base diff --git a/scripts/publish/src/ci/bin.ts b/scripts/publish/src/ci/bin.ts index 58e3882495..a3bd99cd50 100644 --- a/scripts/publish/src/ci/bin.ts +++ b/scripts/publish/src/ci/bin.ts @@ -161,6 +161,7 @@ program .option("--version ", "Override version") .option("--latest ", "Override latest") .option("--branch ", "Override branch name") + .option("--targets ", "Override target groups (comma-separated or 'all')") .action(async (opts) => { const overrides: Parameters[0] = {}; if (opts.trigger) overrides.trigger = opts.trigger as Trigger; @@ -169,9 +170,10 @@ program overrides.latest = opts.latest === "true"; } if (opts.branch) overrides.branch = opts.branch; + if (opts.targets) overrides.targets = opts.targets; const ctx = await resolveContext(overrides); log.info( - `resolved: trigger=${ctx.trigger} version=${ctx.version} npm_tag=${ctx.npmTag} sha=${ctx.sha} latest=${ctx.latest}${ctx.branch !== undefined ? ` branch=${ctx.branch}` : ""}`, + `resolved: trigger=${ctx.trigger} version=${ctx.version} npm_tag=${ctx.npmTag} sha=${ctx.sha} latest=${ctx.latest} targets=${ctx.targets.join(",")}${ctx.branch !== undefined ? ` branch=${ctx.branch}` : ""}`, ); writeContextToGithubOutput(ctx); }); @@ -201,6 +203,7 @@ program includeReleaseOnlyPackages: ctx.trigger === "release", versionOnly: !!opts.versionOnly, repository: opts.repository ?? process.env.GITHUB_REPOSITORY, + targets: ctx.targets, }); await bumpCargoVersions(repoRoot, version, { dryRun: !!opts.dryRun, @@ -235,12 +238,14 @@ program parallel: Number(opts.parallel), retries: Number(opts.retries), releaseMode, + targets: ctx.targets, }); if (!releaseMode) { await repairBranchPreviewLatestTags(repoRoot, { tag, version: ctx.version, includeReleaseOnlyPackages: releaseMode, + targets: ctx.targets, }); } }); diff --git a/scripts/publish/src/lib/context.ts b/scripts/publish/src/lib/context.ts index 28792057b8..885d112946 100644 --- a/scripts/publish/src/lib/context.ts +++ b/scripts/publish/src/lib/context.ts @@ -2,6 +2,13 @@ import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { $ } from "execa"; +import { + ALL_GROUPS, + buildScope, + isAllGroups, + parseTargetGroups, + type TargetGroup, +} from "./scope.js"; /** * Publish context. Resolved once per workflow run by the `context-output` CI @@ -23,6 +30,17 @@ export interface PublishContext { /** Branch name. Only set when trigger === "branch". */ branch?: string; repoRoot: string; + /** + * Selected target groups. Narrowed by the `targets` input on preview + * publishes; always every group on release so a cut is never partial. + */ + targets: TargetGroup[]; + /** Space-separated native build targets in scope (matrix gate reads this). */ + buildTargets: string; + /** Whether the `build-wasm` job runs. */ + buildWasm: boolean; + /** Whether the `docker-images` job runs. */ + buildDocker: boolean; } /** Override set accepted by the local release cutter. */ @@ -32,6 +50,7 @@ export interface ResolveOverrides { latest?: boolean; branch?: string; sha?: string; + targets?: string; } function findRepoRoot(): string { @@ -210,6 +229,23 @@ export async function resolveContext( const npmTag = computeNpmTag(trigger, version, latest, branch); + // Resolve selective-publish scope. Releases always cover every group so a + // cut is never partial; only preview publishes may narrow scope. + const targetsInput = + overrides.targets ?? readInputFromEvent("targets"); + let targets: TargetGroup[]; + if (trigger === "release") { + if (targetsInput && !isAllGroups(parseTargetGroups(targetsInput))) { + throw new Error( + `release publishes must build every target; refusing partial targets="${targetsInput}"`, + ); + } + targets = [...ALL_GROUPS]; + } else { + targets = parseTargetGroups(targetsInput); + } + const scope = buildScope(targets); + return { trigger, version, @@ -218,6 +254,10 @@ export async function resolveContext( latest, branch, repoRoot, + targets, + buildTargets: scope.buildTargets.join(" "), + buildWasm: scope.buildWasm, + buildDocker: scope.buildDocker, }; } @@ -231,6 +271,10 @@ export function writeContextToGithubOutput(ctx: PublishContext): void { console.log(`npm_tag=${ctx.npmTag}`); console.log(`sha=${ctx.sha}`); console.log(`latest=${ctx.latest}`); + console.log(`targets=${ctx.targets.join(",")}`); + console.log(`build_targets=${ctx.buildTargets}`); + console.log(`build_wasm=${ctx.buildWasm}`); + console.log(`build_docker=${ctx.buildDocker}`); if (ctx.branch !== undefined) console.log(`branch=${ctx.branch}`); return; } @@ -240,6 +284,10 @@ export function writeContextToGithubOutput(ctx: PublishContext): void { `npm_tag=${ctx.npmTag}`, `sha=${ctx.sha}`, `latest=${ctx.latest}`, + `targets=${ctx.targets.join(",")}`, + `build_targets=${ctx.buildTargets}`, + `build_wasm=${ctx.buildWasm}`, + `build_docker=${ctx.buildDocker}`, ]; if (ctx.branch !== undefined) lines.push(`branch=${ctx.branch}`); // Append (do not overwrite) in case other steps also wrote to GITHUB_OUTPUT. diff --git a/scripts/publish/src/lib/npm.ts b/scripts/publish/src/lib/npm.ts index 889057cc6e..421e857cd8 100644 --- a/scripts/publish/src/lib/npm.ts +++ b/scripts/publish/src/lib/npm.ts @@ -13,6 +13,7 @@ import { META_PACKAGES, type Package, } from "./packages.js"; +import { scopedFamilies, type TargetGroup } from "./scope.js"; const log = scoped("npm"); @@ -35,6 +36,11 @@ export interface PublishAllOptions { releaseMode?: boolean; /** Include release-only packages like Windows engine-cli artifacts. */ includeReleaseOnlyPackages?: boolean; + /** + * Selected target groups. When set, only in-scope package families are + * published. Omit for a full run. + */ + targets?: TargetGroup[]; } export type PublishStatus = @@ -218,7 +224,7 @@ async function publishOne( export async function repairBranchPreviewLatestTags( repoRoot: string, opts: Required> & - Pick, + Pick, ): Promise { // npm trusted publishing only authenticates `npm publish`; commands such as // `npm dist-tag add` still require a traditional token. Preserve the repair @@ -232,6 +238,7 @@ export async function repairBranchPreviewLatestTags( const previewPrefix = `0.0.0-${opts.tag}.`; const packages = discoverPackages(repoRoot, { includeReleaseOnly: opts.includeReleaseOnlyPackages, + families: opts.targets ? scopedFamilies(opts.targets) : undefined, }); for (const pkg of packages) { @@ -318,10 +325,12 @@ export async function publishAll( const initialBackoffMs = opts.initialBackoffMs ?? 2000; const tag = opts.tag; + const families = opts.targets ? scopedFamilies(opts.targets) : undefined; const packages = discoverPackages(repoRoot, { includeReleaseOnly: opts.includeReleaseOnlyPackages, + families, }); - assertDiscoverySanity(packages); + assertDiscoverySanity(packages, families); log.info( `publishing ${packages.length} packages | tag=${tag} | parallel=${parallel} | retries=${retries}`, diff --git a/scripts/publish/src/lib/packages.ts b/scripts/publish/src/lib/packages.ts index 7a79f8c17d..1745676d4c 100644 --- a/scripts/publish/src/lib/packages.ts +++ b/scripts/publish/src/lib/packages.ts @@ -18,6 +18,7 @@ import { execSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; +import { type PackageFamily, packageFamily } from "./scope.js"; export interface Package { name: string; @@ -29,6 +30,11 @@ export interface Package { export interface DiscoverPackagesOptions { includeReleaseOnly?: boolean; + /** + * When set, only packages whose family is in this set are returned. Used by + * selective preview publishes. Omit (or pass every family) for a full run. + */ + families?: Set; } /** @@ -108,6 +114,7 @@ export function discoverPackages( opts: DiscoverPackagesOptions = {}, ): Package[] { const includeReleaseOnly = opts.includeReleaseOnly ?? true; + const families = opts.families; const packages: Package[] = []; const seen = new Set(); @@ -118,6 +125,7 @@ export function discoverPackages( if (!pkg.name) return; if (!isPublishable(pkg)) return; if (!includeReleaseOnly && RELEASE_ONLY_PACKAGES.has(pkg.name)) return; + if (families && !families.has(packageFamily(pkg.name))) return; if (seen.has(pkg.name)) return; seen.add(pkg.name); packages.push({ @@ -210,24 +218,28 @@ export function buildMetaPlatformMap( * CI if discovery silently regressed. Called at the top of subcommands that * touch the full set. */ -export function assertDiscoverySanity(packages: Package[]): void { +export function assertDiscoverySanity( + packages: Package[], + families?: Set, +): void { + const inScope = (family: PackageFamily) => !families || families.has(family); const byName = new Set(packages.map((p) => p.name)); - const required = [ - "rivetkit", - "@rivetkit/react", - "@rivetkit/rivetkit-napi", - "@rivetkit/engine-cli", - "@rivetkit/cli", - ]; + const required: string[] = []; + if (inScope("rivetkit")) { + required.push("rivetkit", "@rivetkit/react", "@rivetkit/rivetkit-napi"); + } + if (inScope("engine")) required.push("@rivetkit/engine-cli"); + if (inScope("cli")) required.push("@rivetkit/cli"); const missing = required.filter((r) => !byName.has(r)); if (missing.length > 0) { throw new Error( `package discovery missing required packages: ${missing.join(", ")}`, ); } - // Each meta must have at least one platform package. + // Each in-scope meta must have at least one platform package. const metaMap = buildMetaPlatformMap(packages); for (const { meta } of META_PACKAGES) { + if (!inScope(packageFamily(meta))) continue; const plats = metaMap.get(meta) ?? []; if (plats.length === 0) { throw new Error( diff --git a/scripts/publish/src/lib/scope.ts b/scripts/publish/src/lib/scope.ts new file mode 100644 index 0000000000..7abbbc8f66 --- /dev/null +++ b/scripts/publish/src/lib/scope.ts @@ -0,0 +1,118 @@ +/** + * Selective-publish scope. Single source of truth mapping a preview's selected + * target groups to the native build targets, the wasm/docker jobs, and the npm + * package families that are in scope for the run. + * + * Preview publishes (`trigger === "branch"`) may narrow scope to speed up the + * common single-target case. Release publishes always cover every group so a + * cut is never partial. + */ + +/** User-facing target groups selected via the `targets` workflow_dispatch input. */ +export type TargetGroup = "rivetkit" | "container-runner" | "engine" | "cli"; + +/** Native artifacts produced by the `build` matrix (matrix `build_target`). */ +export type BuildTarget = "rivetkit-napi" | "engine" | "container-runner" | "cli"; + +/** + * npm package families. `container-runner` has no npm package (it ships as an + * R2 binary only), so it is not a family. + */ +export type PackageFamily = "rivetkit" | "engine" | "cli"; + +export const ALL_GROUPS: readonly TargetGroup[] = [ + "rivetkit", + "container-runner", + "engine", + "cli", +]; + +/** Classify a publishable package into its family by name. */ +export function packageFamily(name: string): PackageFamily { + if (name === "@rivetkit/engine-cli" || name.startsWith("@rivetkit/engine-cli-")) { + return "engine"; + } + if (name === "@rivetkit/cli" || name.startsWith("@rivetkit/cli-")) { + return "cli"; + } + // Everything else (rivetkit, @rivetkit/rivetkit-napi[-*], wasm, react, + // engine SDK + shared TS packages) is part of the rivetkit family. + return "rivetkit"; +} + +/** Parse the raw `targets` input into a validated, de-duplicated group list. */ +export function parseTargetGroups(raw: string | undefined): TargetGroup[] { + if (!raw || raw.trim() === "" || raw.trim() === "all") { + return [...ALL_GROUPS]; + } + const parts = raw + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0); + const out: TargetGroup[] = []; + for (const p of parts) { + if (p === "all") return [...ALL_GROUPS]; + if (!(ALL_GROUPS as readonly string[]).includes(p)) { + throw new Error( + `unknown target "${p}" (expected one of: ${["all", ...ALL_GROUPS].join(", ")})`, + ); + } + if (!out.includes(p as TargetGroup)) out.push(p as TargetGroup); + } + if (out.length === 0) return [...ALL_GROUPS]; + return out; +} + +export function isAllGroups(groups: readonly TargetGroup[]): boolean { + return ALL_GROUPS.every((g) => groups.includes(g)); +} + +/** npm families that should be built + published for the selected groups. */ +export function scopedFamilies( + groups: readonly TargetGroup[], +): Set { + const families = new Set(); + if (groups.includes("rivetkit")) families.add("rivetkit"); + if (groups.includes("engine")) families.add("engine"); + if (groups.includes("cli")) families.add("cli"); + return families; +} + +export interface BuildScope { + /** Native `build` matrix targets in scope. */ + buildTargets: BuildTarget[]; + /** Whether the `build-wasm` job runs. */ + buildWasm: boolean; + /** Whether the `docker-images` job runs. */ + buildDocker: boolean; +} + +/** + * Resolve which native builds a set of groups requires. + * + * `cli` implies an `engine` build because the CLI platform packages bundle the + * rivet-engine binary via a file copy at publish time. That does not put the + * `engine` npm family in publish scope; it only ensures the binary artifact + * exists to copy. + */ +export function buildScope(groups: readonly TargetGroup[]): BuildScope { + const targets = new Set(); + let buildWasm = false; + let buildDocker = false; + if (groups.includes("rivetkit")) { + targets.add("rivetkit-napi"); + buildWasm = true; + } + if (groups.includes("engine")) { + targets.add("engine"); + buildDocker = true; + } + if (groups.includes("cli")) { + targets.add("cli"); + targets.add("engine"); + } + if (groups.includes("container-runner")) { + targets.add("container-runner"); + } + return { buildTargets: [...targets], buildWasm, buildDocker }; +} diff --git a/scripts/publish/src/lib/version.ts b/scripts/publish/src/lib/version.ts index bd8d531212..3752c00fc3 100644 --- a/scripts/publish/src/lib/version.ts +++ b/scripts/publish/src/lib/version.ts @@ -26,6 +26,11 @@ import { discoverPackages, type Package, } from "./packages.js"; +import { + packageFamily, + scopedFamilies, + type TargetGroup, +} from "./scope.js"; const log = scoped("version"); @@ -87,6 +92,13 @@ export interface BumpOptions { versionOnly?: boolean; /** GitHub repository slug recorded in publish-time package metadata. */ repository?: string; + /** + * Selected target groups. When set, only in-scope package families are + * bumped, and dependencies on out-of-scope families are pinned to their + * latest already-published version instead of the (never-built) new + * version. Omit for a full run. Ignored in `versionOnly` mode. + */ + targets?: TargetGroup[]; } export function githubRepositoryUrl(repository: string): string { @@ -128,13 +140,32 @@ export async function bumpPackageJsons( version: string, opts: BumpOptions = {}, ): Promise { + const families = opts.targets ? scopedFamilies(opts.targets) : undefined; const packages = discoverPackages(repoRoot, { includeReleaseOnly: opts.includeReleaseOnlyPackages, + families, }); const packageNames = new Set(packages.map((p) => p.name)); const metaPlatformMap = buildMetaPlatformMap(packages); const versionOnly = opts.versionOnly ?? false; + // Cache `npm view version` lookups for out-of-scope dependencies so a + // dep referenced by several packages is only resolved once. + const latestCache = new Map(); + const resolveLatestPublished = async (dep: string): Promise => { + const cached = latestCache.get(dep); + if (cached) return cached; + const { stdout } = await $`npm view ${dep} version`; + const latest = stdout.trim(); + if (!latest) { + throw new Error( + `could not resolve latest published version for out-of-scope dependency ${dep}`, + ); + } + latestCache.set(dep, latest); + return latest; + }; + let updated = 0; for (const pkg of packages) { const pkgJsonPath = join(pkg.dir, "package.json"); @@ -172,6 +203,19 @@ export async function bumpPackageJsons( dep.startsWith("@rivetkit/") || dep === "rivetkit"; if (!isOurPkg) continue; + // A dependency on a family that is out of scope this run was + // never rebuilt or republished at `version`. Pin it to the + // latest already-published version so the package still + // installs (e.g. a rivetkit-only preview keeps a working + // reference to the last published @rivetkit/engine-cli). + if (families && !families.has(packageFamily(dep))) { + const latest = await resolveLatestPublished(dep); + deps[dep] = latest; + log.info( + `pinning out-of-scope dep ${pkg.name} -> ${dep}@${latest}`, + ); + continue; + } deps[dep] = version; } }