Skip to content

feat(site-deploy): deploy a static build as an immutable release - #1776

Open
pyramation wants to merge 2 commits into
mainfrom
feat/site-deploy-library
Open

feat(site-deploy): deploy a static build as an immutable release#1776
pyramation wants to merge 2 commits into
mainfrom
feat/site-deploy-library

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

New package @constructive-io/site-deploy: the client half of a static deploy, which the UI and CI would otherwise each reimplement. The server already owns everything below the manifest (merkle commits, refs, time travel, CAS resolution at the edge); what nothing shipped was the four mechanical steps in front of it — hash the build, upload the missing bytes, write one manifest, point something at the commit.

const { commitId, uploaded, skipped, previewUrl } = await deploySite({
  api, siteId, databaseId, bucketKey: 'site-docs',
  source: './dist',        // or the files themselves, for a browser UI
  publish: true,           // or preview: 'pr-42', previewApex: 'preview.example.com'
});

The layering follows the generic/specific split: @constructive-io/upload-client stays generic and knows nothing about sites (it contributes the presigned PUT); the site-specific parts — cas/sha256/<hash> keys, the manifest, the release row, publish/preview — live here, so the dependency only points one way.

Two behaviours are the reason this is a library rather than a snippet:

  • Dedupe is the incremental deploy. The bulk mutation reports which content hashes the bucket already has; only the rest are PUT. A one-file change re-uploads one file however large the site is (test: re-uploads only the changed file).
  • Ordering is a correctness contract, not a style choice: bytes land before the manifest names them, and the manifest lands before anything points at it. Every failure path is a throw with a DeployError.code, so an upload failure can never become a published manifest with holes in it, and a failed publish leaves a complete, retryable release + commit.

Also non-obvious from the diff:

  • The release row is UNIQUE (site_id), so the first deploy must createSiteRelease and every later one updateSiteRelease — resolved internally by querying for the row, rather than making callers know which they need. databaseId is therefore only required for a site's first deploy.
  • The returned key is verified to be exactly the cas/sha256/<hash> that was asked for; the server does not enforce that form, and a mismatch would publish a manifest pointing at bytes the gateway cannot resolve. Checked before any PUT.
  • A release row that comes back without commitId/storeId is a hard failure (RELEASE_NOT_VERSIONED), not a result with empty strings: without a commit it cannot be published, rolled back to, or previewed.
  • Field names are derived per scope (deployNames('platform')uploadPlatformFiles, createPlatformSiteRelease, …) since the surface is generated per plane; preview procedures are unprefixed today and individually overridable.
  • Path normalization rejects .. and anything under cas/ instead of dropping it, and dotfiles are included (.well-known/… is a real route) — a file skipped by an invisible rule is a silently broken site.

Publishing and rollback are the same operation, exported for the rollback direction:

await publishCommit(api, deployNames(), siteId, previousCommitId);

Testing

44 unit tests, no database and no network: a mock server models the two server behaviours the pipeline depends on — dedupe, and the trigger stamping a fresh commit on every manifest write — plus an injectable putObject. Covered: first-deploy create vs. later-deploy update, dedupe skip, single-file change, batch chunking, key mismatch failing before upload, partial-upload failure leaving no manifest, retry of a transient 5xx, publish failure leaving the release usable, preview ref/hostname, skipIfUnchanged, dryRun, abort, progress events, and a real directory walk.

Live-DB proof of deploy → publish → rollback stays in constructive-db, where it already exists (site-releases-merkle-timetravel, static-release.e2e).

Context: constructive-planning#1886 §7.

Link to Devin session: https://app.devin.ai/sessions/ecf6201520ef4ac984f4dd610ceea5b3
Requested by: @pyramation

Adds @constructive-io/site-deploy: walk/hash a build, upload only the bytes
the server does not already have, commit one manifest, and optionally publish
or point a preview ref at the resulting commit.

Builds on @constructive-io/upload-client for the generic presigned PUT; the
site-specific half (CAS keys, manifest, release row, publish/preview) lives
here so the UI and CI do not each reimplement it.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review complete. 🟡 4 medium

💬 Inline comments (4)

  • 🟡 skipIfUnchanged silently skips requested publish/previewdeploy.ts:112
  • 🟡 Symlink-following reads arbitrary files into deploywalk.ts:39
  • 🟡 dryRun still performs a server-side writedeploy.ts:140
  • 🟡 Empty (0-byte) files fail the whole deploymanifest.ts:104
🧹 Nitpicks (2) — 🟢 2 low
  • 🟢 Object.prototype path names break manifest build (manifest.ts:101) — buildManifest keys the manifest in a plain {} and checks duplicates with if (entries[file.path]) (manifest.ts:101), so a file whose logical path is __proto__, constructor, toString, or any other Object.prototype member makes the check resolve to an inherited truthy value and throws a spurious Duplicate logical path on the first occurrence.
  • 🟢 MISSING_DATABASE_ID guard fires after upload (deploy.ts:292) — On the first deploy with no databaseId, deploySite uploads every file's bytes (uploadMissing at deploy.ts:168) before createRelease throws MISSING_DATABASE_ID at deploy.ts:292.

This PR introduces the packages/site-deploy package: a CLI/library that walks a static build tree, hashes files into a CAS key space, diffs against the server, uploads missing bytes, and publishes a release manifest through the GraphQL API, plus its test suite, jest config, and README.

Files Change
src/walk.ts, src/manifest.ts, src/cas-upload.ts Walk the build directory, build a content-addressed manifest, and upload missing files to the CAS bucket.
src/deploy.ts, src/documents.ts, src/types.ts Orchestrate the deploy (diff/upload/publish/preview) and derive GraphQL mutation documents and types.
src/index.ts, src/content-type.ts Public entry point and content-type mapping.
__tests__/*, jest.config.js, package.json, tsconfig*.json, README.md Tests, tooling, and documentation for the new package.

The main risks are edge-case handling: skipIfUnchanged can silently skip an explicit publish/preview request, empty (0-byte) files abort the whole deploy, symlinks are followed and can read arbitrary local files into a public bucket, dryRun still performs a server-side write, and a missing databaseId is only detected after the full upload has already happened.

Reviewed commit: 909e0a6

@tenki-reviewer tenki-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds a new site-deploy package that walks a static build directory, content-addresses files, uploads them to a CAS bucket, and publishes a manifest/release via GraphQL, with several edge-case correctness and security gaps.

Key findings

  • 🟡 skipIfUnchanged silently skips requested publish/previewdeploy.ts:112
  • 🟡 Symlink-following reads arbitrary files into deploywalk.ts:39
  • 🟡 dryRun still performs a server-side writedeploy.ts:140
  • 🟡 Empty (0-byte) files fail the whole deploymanifest.ts:104

Comment on lines +112 to +127
) {
onProgress?.({ type: 'unchanged', commitId: existing.commitId });
return {
commitId: existing.commitId,
storeId: existing.storeId,
releaseId: existing.id,
manifest,
files: manifest.file_count,
uploaded: 0,
skipped: manifest.file_count,
bytesUploaded: 0,
published: false,
previewUrl: null,
unchanged: true,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

skipIfUnchanged silently skips requested publish/preview

When skipIfUnchanged matches an existing manifest, deploySite returns early at deploy.ts:112 with published: false and previewUrl: null, never executing the publish or preview pointer moves even when those options were set. A caller that runs deploy({ publish: true, skipIfUnchanged: true }) on an unchanged tree receives a success result while site.activeCommitId is never moved, leaving the site unpublished despite an explicit go-live request.

📋 Prompt for AI Agents

In packages/site-deploy/src/deploy.ts lines 112-127, the skipIfUnchanged early-return bypasses the publish and preview pointer moves. After the unchanged check returns the existing release, if options.publish is true still call publishCommit(api, names, siteId, existing.commitId) and set published: true; if options.preview !== undefined still call pointPreview(...) and set previewUrl. Do not unconditionally hardcode published: false/previewUrl: null in this branch, because a caller requesting publish must not get a silent no-op go-live.

// Symlinks to files are followed by readFile; anything that is neither a
// file nor a directory (socket, fifo) has no meaning in a static site.
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
yield { path: logical, bytes: new Uint8Array(await readFile(child)) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 security · medium

Symlink-following reads arbitrary files into deploy

walkDirectory treats any symlink as a file and reads it with readFile (packages/site-deploy/src/walk.ts:39), so a symlink inside the build tree that points outside the directory (e.g. .env or source) is read and uploaded to the site's public bucket. A build influenced by a compromised dependency or untrusted artifact can therefore exfiltrate arbitrary local files into a public release.

📋 Prompt for AI Agents

In packages/site-deploy/src/walk.ts around lines 36-39, stop following symlinks that escape the build directory. Resolve each entry with fs.realpath and verify the resolved path stays within realpath(dir) before reading; skip (or throw a DeployError) for any symlink whose target resolves outside the root. This prevents a malicious build from reading arbitrary local files into the deployed site. Update the comment on lines 36-37 to reflect the new behavior.

Comment on lines +140 to +147
const diff = await diffCas(files, uploadOptions);
onProgress?.({
type: 'diffed',
files: manifest.file_count,
toUpload: diff.missing.length,
skipped: diff.deduplicated.length,
bytesToUpload: diff.missing.reduce((sum, entry) => sum + entry.file.size, 0),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

dryRun still performs a server-side write

The dryRun path in deploy.ts calls diffCas (line 140) before returning at line 147, and diffCas executes the uploadFiles mutation, which the server documents as creating file rows (graphile/graphile-presigned-url-plugin/src/plugin.ts:346). A run documented as 'hash and diff only' therefore still writes rows on the server, leaving orphaned file rows behind when the bytes are never PUT.

📋 Prompt for AI Agents

In packages/site-deploy/src/deploy.ts, the dryRun branch (around lines 140-147) still calls diffCas, which runs the uploadFiles mutation that creates file rows server-side (see graphile/graphile-presigned-url-plugin/src/plugin.ts:346). Make dryRun side-effect-free: when dryRun is true, skip the diffCas mutation (or derive the diff from a read-only mechanism) so no server write occurs, then return the hashed/diffed result as before. Update the dryRun test to assert no uploadFiles call is made.

Comment on lines +104 to +108
entries[file.path] = {
hash: file.hash,
content_type: file.contentType,
size: file.size,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Empty (0-byte) files fail the whole deploy

buildManifest accepts a 0-byte file and emits a manifest entry with size: 0, but the server's processSingleFile rejects size <= 0 with INVALID_FILE_SIZE (graphile/graphile-presigned-url-plugin/src/plugin.ts:613), so any empty file in a build aborts the entire deploy via diffCas. A static build can legitimately ship an empty placeholder (e.g. .gitkeep), and the client gives no signal that it is unsupported.

📋 Prompt for AI Agents

In packages/site-deploy/src/manifest.ts inside buildManifest (around lines 100-115), before writing entries[file.path], check if (file.size <= 0) and throw new DeployError('INVALID_PATH', ...) with a message explaining that empty (0-byte) files are rejected by the server's upload surface, so the deploy fails early and clearly rather than surfacing the server's INVALID_FILE_SIZE from diffCas.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant