Files
PaperClipAI/server/src/__tests__/cloud-image-bundled-plugins.test.ts
T
Nicky LeachandPaperclip ca92f727c5 ci: publish the cloud image in its own parallel job (#10408)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The CI workflow is responsible for producing and publishing the
Docker images that power Paperclip deployments
> - The cloud image publish was previously coupled to the stock publish
job, so a failure or delay in one path could gate the other
> - That coupling makes the release pipeline less resilient than it
needs to be
> - This pull request gives the cloud publish its own top-level job so
both publishes can run in parallel without a `needs:` dependency
> - The benefit is better failure isolation and less wasted time when
one publish path is slow or broken

## Linked Issues or Issue Description

No public GitHub issue was found for this change.

Problem statement:
- The cloud image publish was implemented as trailing work inside the
stock publish job.
- That setup meant the cloud publish could be delayed or skipped if the
stock job failed early.
- The desired behavior is for the cloud publish to run independently so
a failure in one publish path does not gate the other.

Proposed solution:
- Split the cloud publish into its own top-level workflow job.
- Keep the same cloud-specific build settings and cache behavior.
- Preserve the existing top-level concurrency behavior.

Alternatives considered:
- Keeping both publishes in one job with conditionals or later steps.
Rejected because it still couples success and runtime between the two
publish paths.

## What Changed

- Split the cloud image publish into a separate top-level Docker
workflow job.
- Removed the dependency coupling so the cloud job does not need the
stock job.
- Expanded the drift-guard test to assert the two-job structure and the
absence of `needs:` on the cloud job.

## Verification

- The workflow YAML was parsed successfully and confirmed to contain two
jobs: `build-and-push` and `build-and-push-cloud`.
- The cloud job was confirmed to have no `needs:` entry.
- The drift-guard assertions were reproduced in a dependency-free
harness and passed.
- PR #10408 completed GitHub Actions with all required checks green,
including the e2e shards.
- Greptile review completed at 5/5 with no unresolved comments.
- No documentation files changed because this is a workflow/test-only
change.

## Risks

- The workflow now duplicates the prep steps across two runners, so any
shared setup change must be kept in sync between both jobs.
- The new job increases workflow surface area slightly, which can make
future maintenance more verbose.
- Overall risk is low because the change is limited to CI orchestration
and test coverage.

## Model Used

OpenAI Codex (GPT-5, tool-using code assistant)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-28 15:38:01 -07:00

123 lines
5.6 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { BUNDLED_PLUGIN_CATALOG } from "../services/bundled-plugins.js";
/**
* Drift guard for the cloud image variant (Dockerfile `cloud` target).
*
* The cloud image builds the sandbox-provider plugins named in the
* CLOUD_BUNDLED_PLUGINS build arg so managed instances can auto-install
* them from the bundled catalog at boot. That contract spans three places
* that nothing else ties together: the Dockerfile ARG default, the docker
* workflow's build-arg, and BUNDLED_PLUGIN_CATALOG. A rename or removal in
* any one of them would otherwise surface only when the image build fails
* on master — or worse, as a silent "bundle not present" skip at instance
* boot.
*/
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8");
const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker.yml"), "utf8");
function parseList(source: string, pattern: RegExp, label: string): string[] {
const match = source.match(pattern);
expect(match, `${label} must declare CLOUD_BUNDLED_PLUGINS`).toBeTruthy();
const names = (match?.[1] ?? "").trim().split(/\s+/).filter(Boolean);
expect(names.length, `${label} CLOUD_BUNDLED_PLUGINS must not be empty`).toBeGreaterThan(0);
return names;
}
const dockerfileDefault = parseList(
dockerfile,
/^ARG CLOUD_BUNDLED_PLUGINS="([^"]*)"/m,
"Dockerfile",
);
const workflowArg = parseList(
workflow,
/^\s*CLOUD_BUNDLED_PLUGINS=(.*)$/m,
"docker workflow",
);
describe("cloud image bundled plugins", () => {
it("keeps the Dockerfile default and the workflow build-arg in sync", () => {
expect(workflowArg).toEqual(dockerfileDefault);
});
it.each([...new Set([...dockerfileDefault, ...workflowArg])])(
"plugin %s is buildable and resolvable by the auto-installer",
(name) => {
const dir = path.join(repoRoot, "packages", "plugins", "sandbox-providers", name);
expect(existsSync(dir), `${dir} must exist`).toBe(true);
expect(
existsSync(path.join(dir, "src", "manifest.ts")),
`${name} must have src/manifest.ts so the build produces dist/manifest.js`,
).toBe(true);
const packageJson = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as {
scripts?: Record<string, string>;
};
expect(packageJson.scripts?.build, `${name} must have a build script`).toBeTruthy();
// The auto-installer resolves catalog keys to relative paths; a plugin
// baked into the image but absent from the catalog (or vice versa)
// can never be auto-installed.
const catalogEntry = BUNDLED_PLUGIN_CATALOG.find(
(entry) => entry.relativePath === `sandbox-providers/${name}`,
);
expect(catalogEntry, `${name} must be listed in BUNDLED_PLUGIN_CATALOG`).toBeTruthy();
},
);
it("pins the default image build to the production target", () => {
// The Dockerfile's final stage is `cloud`; without an explicit target
// the workflow's main build would silently publish the cloud variant
// to the self-hosted tags.
expect(workflow).toMatch(/^\s*target: production$/m);
});
it("publishes the cloud image in its own job with no needs coupling", () => {
// The cloud publish runs as its own top-level job so the stock/production
// publish can never gate, delay, or skip it. Both jobs share only the
// single top-level concurrency slot; there is deliberately no `needs:`
// between them, so a failure in one is never coupled to the other.
const jobsSection = workflow.slice(workflow.indexOf("\njobs:\n"));
const headers = [...jobsSection.matchAll(/^ {2}([\w-]+):[^\n]*$/gm)];
expect(
headers.length,
"docker.yml must declare at least two jobs under jobs:",
).toBeGreaterThanOrEqual(2);
// Locate the job block that carries the cloud build (target: cloud) and
// assert it declares no `needs:` — coupling it to another job would
// reintroduce the shared failure the split job exists to remove.
const cloudHeaderIdx = headers.findIndex((header, i) => {
const start = header.index ?? 0;
const end = headers[i + 1]?.index ?? jobsSection.length;
return jobsSection.slice(start, end).includes("target: cloud");
});
expect(cloudHeaderIdx, "one job must build the cloud target").toBeGreaterThanOrEqual(0);
const start = headers[cloudHeaderIdx].index ?? 0;
const end = headers[cloudHeaderIdx + 1]?.index ?? jobsSection.length;
const cloudJobBlock = jobsSection.slice(start, end);
expect(
cloudJobBlock,
"the cloud job must not couple to another job via needs:",
).not.toMatch(/^\s*needs:/m);
});
it("throttles the docker workflow with cancel-in-progress: false", () => {
// Concurrency is declared at the workflow (top) level so a single group
// spans the whole run, and cancel-in-progress is false so an in-flight
// image build always finishes — a newer push only supersedes the pending
// slot instead of killing the build that is already publishing.
expect(workflow).toMatch(/^concurrency:$/m);
// Pin the per-ref group key: without it the block could keep
// cancel-in-progress: false yet lose the group that scopes serialization
// to a single ref, silently changing which builds queue behind each other.
expect(workflow).toContain("group: docker-${{ github.ref }}");
expect(workflow).toContain("cancel-in-progress: false");
expect(workflow).not.toContain("cancel-in-progress: true");
});
});