mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 06:15:21 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Core publishes standard images and source verification for downstream services. > - Managed services can now compose private images from the signed standard image. > - Core still builds a second public cloud image on every master push and release. > - That duplicate producer consumes build capacity and retains an obsolete readiness contract. > - This pull request retires recurring cloud publication while preserving the standard producer and rollback artifacts. ## Linked Issues or Issue Description Refs #13797 and #13789. Related: #12856 changes image dependency packaging; it does not retire this producer. **What existing behavior does this improve?** Core's recurring Docker publication and Cloud readiness workflow. **Current behavior** Master pushes call the legacy cloud publisher from Cloud readiness. Release tags and manual Docker runs call it too. Canary promotion also requires the legacy image. **Proposed behavior** Publish standard Core images and retain `Cloud source verified v1`. Let downstream services build their managed image. Keep explicit commit previews and existing images available. ## What Changed - Remove `docker-cloud.yml`, its master and release callers, and its unused cache selector. - Remove the legacy image/migrator wait and `Cloud deployable v1` job. Keep the full source verification workflow and exact source-proof name. - Make canary promotion inspect and promote the standard image only. - Preserve signed standard-image publication, direct migrator publication, and explicit `release.yml` previews. The preview path still uses the Dockerfile `cloud` target. - Update workflow, preview, build-stamp, and packaging tests. Exercise the promotion shell with mocked registry commands, including missing-image and missing-tag cases. - Document frozen legacy aliases, consumer requirements, preview compatibility, and rollback retention. ## Verification - All 377 workflow tests pass: `node --test .github/scripts/tests/*.test.mjs`. - All 129 release-registry tests pass: `pnpm test:release-registry`. - Focused source-proof, standard-image, preview, and workflow tests pass: 256 tests. - Focused image packaging/build-stamp tests pass: 16 tests. - Actionlint passes on all three changed workflow files. `git diff --check` passes. - Full local `pnpm build` and `pnpm -r typecheck` pass. - The policy follow-up updates an old assertion that required the removed readiness job. All 37 source-proof/release-workflow tests pass locally. - Full local `pnpm test:run` did not complete successfully while the Mac ran out of disk space. No full-suite pass is claimed. Removed 1.2 GiB of generated Cargo output from this isolated worktree with `cargo clean`. GitHub CI passed on the final head: 52 successful checks and 2 optional skips. - Fresh Greptile review for `4f5fe1951f0bd7f7739cf6655d395ff78f1ed944`: **5/5**, successful current-head check, zero review threads. - September 23 refresh: the unchanged PR head merges cleanly with current master `db8f8fe5b73a2697684a30261b0d306a9c631aba`. In an isolated temporary worktree, all 377 workflow tests and 29 release/preview tests pass on the combined tree. `git diff --cached --check` passes. - Refreshed Actionlint workflow validation passes with ShellCheck disabled. Full Actionlint reports the same 10 existing ShellCheck diagnostics as master, with no added diagnostics. No source changes or new PR commits were needed. - The full local build/typecheck and current-head Linux CI results above remain the verification for the unchanged PR head. They were not rerun for this metadata-only refresh. No image publication or tenant deployment was initiated for this refresh. ## Risks **Deployment prerequisite satisfied (September 23):** The combined cleanup release is deployed to staging and production, and production Support is verified. Active managed-fleet automation uses standard-image composition. Explicit immutable previews remain supported by the retained preview publisher. This PR is ready for maintainer review; keep auto-merge disabled and wait for explicit merge authorization. - A consumer still selecting `Cloud deployable v1` will stop advancing at the last legacy-ready commit. Confirm active automatic consumers use the standard-image composition contract before merge. - Legacy cloud release-channel aliases stop advancing. Standard self-hosted aliases continue. - This PR deletes no registry images, cache tags, migrators, credentials, or runner infrastructure. Existing immutable releases remain usable for rollback. - Explicit legacy previews remain for commit-specific operator deployments. Retiring that compatibility path requires a separate consumer migration. - These changes affect CI publication, not database schema or application behavior. ## Model Used OpenAI Codex, GPT-6. The runtime does not expose a more specific model identifier or context-window size. Used repository inspection, reasoning, code editing, shell tools, and test execution. ## 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>
202 lines
8.0 KiB
TypeScript
202 lines
8.0 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
/**
|
|
* Drift guard for the explicit preview image's bundled Sentry server package
|
|
* (Dockerfile `cloud` target).
|
|
*
|
|
* The self-hosted image, built from the `production` target, keeps
|
|
* `@sentry/node` as a true optional peer dependency: the operator installs
|
|
* it themselves. The explicit preview image installs the packages the
|
|
* `CLOUD_BUNDLED_SERVER_DEPS` build argument names, so a managed tenant
|
|
* gets server error reports with no separate install step. The stage
|
|
* reads each package's version from the `peerDependencies` block of
|
|
* `server/package.json` at build time, so the version has one committed
|
|
* home. This test pins the invariants that nothing else ties together:
|
|
* every Dockerfile instruction that installs `@sentry/node` sits strictly
|
|
* after the `production` stage body ends; the Dockerfile and the docker
|
|
* workflow carry no literal version pin (they read the version from
|
|
* `server/package.json` at build time instead); the `cloud-server-deps`
|
|
* stage declares the `CLOUD_BUNDLED_SERVER_DEPS` build argument with a
|
|
* default that names `@sentry/node`; and no committed manifest re-declares the
|
|
* version.
|
|
*/
|
|
|
|
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", "release.yml"), "utf8");
|
|
const serverPackageJson = JSON.parse(
|
|
readFileSync(path.join(repoRoot, "server", "package.json"), "utf8"),
|
|
) as { peerDependencies?: Record<string, string> };
|
|
|
|
const declaredVersion = serverPackageJson.peerDependencies?.["@sentry/node"];
|
|
|
|
const probeSource = readFileSync(
|
|
path.join(repoRoot, "scripts", "assert-cloud-image-sentry.mjs"),
|
|
"utf8",
|
|
);
|
|
|
|
/**
|
|
* Build a throwaway directory that stands in for the image's `/app/server`
|
|
* directory: a copy of the probe script (module resolution walks from a
|
|
* script's own location, so the copy must sit where the fake `server`
|
|
* directory expects it), a minimal but real `@sentry/node` package, and,
|
|
* when `withTsxLoader` is true, a symbolic link at `node_modules/tsx` that
|
|
* mirrors the real workspace install (a link out to a separate store
|
|
* directory holding `dist/loader.mjs`). Omitting the link stands in for the
|
|
* Sentry copy removing or shadowing it.
|
|
*/
|
|
function buildFakeServerDir(withTsxLoader: boolean) {
|
|
const root = mkdtempSync(path.join(tmpdir(), "cloud-image-sentry-probe-"));
|
|
const serverDir = path.join(root, "server");
|
|
const sentryDir = path.join(serverDir, "node_modules", "@sentry", "node");
|
|
mkdirSync(sentryDir, { recursive: true });
|
|
writeFileSync(
|
|
path.join(sentryDir, "package.json"),
|
|
JSON.stringify({ name: "@sentry/node", version: "9.9.9", type: "module", main: "index.mjs" }),
|
|
);
|
|
writeFileSync(path.join(sentryDir, "index.mjs"), "export {};\n");
|
|
|
|
if (withTsxLoader) {
|
|
const tsxStoreDist = path.join(root, "tsx-store", "dist");
|
|
mkdirSync(tsxStoreDist, { recursive: true });
|
|
writeFileSync(path.join(tsxStoreDist, "loader.mjs"), "export {};\n");
|
|
symlinkSync(path.join("..", "..", "tsx-store"), path.join(serverDir, "node_modules", "tsx"));
|
|
}
|
|
|
|
const probeCopy = path.join(serverDir, "probe.mjs");
|
|
writeFileSync(probeCopy, probeSource);
|
|
return { root, probeCopy };
|
|
}
|
|
|
|
function runProbe(probeCopy: string) {
|
|
return spawnSync(process.execPath, [probeCopy], { encoding: "utf8" });
|
|
}
|
|
|
|
describe("cloud image Sentry install", () => {
|
|
it("declares @sentry/node as an optional peer in server/package.json", () => {
|
|
expect(
|
|
declaredVersion,
|
|
"server/package.json must declare @sentry/node as an optional peer",
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("installs @sentry/node only after the production stage body ends", () => {
|
|
const stageHeaderPattern = /^FROM\s+\S+\s+AS\s+(\S+)/gim;
|
|
const stages = [...dockerfile.matchAll(stageHeaderPattern)].map((match) => ({
|
|
name: match[1],
|
|
index: match.index ?? 0,
|
|
}));
|
|
|
|
const productionIndex = stages.findIndex((stage) => stage.name.toLowerCase() === "production");
|
|
expect(productionIndex, "the Dockerfile must declare a production stage").toBeGreaterThanOrEqual(0);
|
|
|
|
// The next declared stage after `production` marks where its body ends.
|
|
const productionBodyEnd = stages[productionIndex + 1]?.index ?? dockerfile.length;
|
|
|
|
const sentryMentionOffsets = [...dockerfile.matchAll(/@sentry\/node/g)].map(
|
|
(match) => match.index ?? 0,
|
|
);
|
|
expect(
|
|
sentryMentionOffsets.length,
|
|
"the Dockerfile must install @sentry/node somewhere, for the cloud image variant",
|
|
).toBeGreaterThan(0);
|
|
|
|
for (const offset of sentryMentionOffsets) {
|
|
expect(
|
|
offset,
|
|
"every @sentry/node mention must sit after the production stage body ends, " +
|
|
"so the self-hosted target never installs it",
|
|
).toBeGreaterThanOrEqual(productionBodyEnd);
|
|
}
|
|
});
|
|
|
|
it("copies the installed package into the cloud stage's server node_modules", () => {
|
|
expect(dockerfile).toMatch(
|
|
/^COPY --chown=node:node --from=[\w-]+ \S+ \S*server\/node_modules$/m,
|
|
);
|
|
});
|
|
|
|
it("reads the installed version from server/package.json instead of a second hardcoded copy", () => {
|
|
// Matches a literal pin such as "@sentry/node@10.71.0", not a shell
|
|
// variable interpolation such as "@sentry/node@${version}".
|
|
const versionPinPattern = /@sentry\/node@(\d[^\s"'`]*)/g;
|
|
|
|
for (const source of [
|
|
{ label: "Dockerfile", text: dockerfile },
|
|
{ label: "docker workflow", text: workflow },
|
|
]) {
|
|
for (const match of source.text.matchAll(versionPinPattern)) {
|
|
expect(
|
|
match[1],
|
|
`${source.label} pins @sentry/node@${match[1]}, which must equal the declared ` +
|
|
`optional peer version ${declaredVersion}`,
|
|
).toBe(declaredVersion);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("declares the CLOUD_BUNDLED_SERVER_DEPS build argument with a default that names @sentry/node", () => {
|
|
const argPattern = /^ARG\s+CLOUD_BUNDLED_SERVER_DEPS="([^"]*)"/m;
|
|
const match = dockerfile.match(argPattern);
|
|
expect(
|
|
match,
|
|
"the Dockerfile must declare ARG CLOUD_BUNDLED_SERVER_DEPS with a quoted default value",
|
|
).not.toBeNull();
|
|
|
|
const names = (match?.[1] ?? "").split(/\s+/).filter(Boolean);
|
|
expect(
|
|
names,
|
|
"the CLOUD_BUNDLED_SERVER_DEPS default must name @sentry/node",
|
|
).toContain("@sentry/node");
|
|
});
|
|
|
|
it("declares no committed manifest that re-states the version", () => {
|
|
expect(
|
|
existsSync(path.join(repoRoot, "docker", "cloud-server-deps")),
|
|
"docker/cloud-server-deps must not exist; the version has one home, " +
|
|
"server/package.json's peerDependencies block",
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("cloud image Sentry probe: the server's tsx loader", () => {
|
|
it("exits non-zero and names the loader path when server/node_modules/tsx does not resolve", () => {
|
|
const { root, probeCopy } = buildFakeServerDir(false);
|
|
try {
|
|
const result = runProbe(probeCopy);
|
|
expect(result.status, "the probe must fail loudly, not boot a broken image").not.toBe(0);
|
|
expect(
|
|
result.stderr,
|
|
"the error must name the exact path the production CMD boots through",
|
|
).toContain(path.join("node_modules", "tsx", "dist", "loader.mjs"));
|
|
expect(result.stdout, "a failed probe must not print a version string").toBe("");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("still prints only the installed @sentry/node version when the loader resolves", () => {
|
|
const { root, probeCopy } = buildFakeServerDir(true);
|
|
try {
|
|
const result = runProbe(probeCopy);
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toBe("9.9.9");
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|