Skip to content

Commit 595bf5a

Browse files
Fix Vercel upload contract (POST /v2/files, SHA1) and lengthen Netlify wait
The real-account smoke test exposed that Vercel's file upload endpoint moved from POST /v13/files to POST /v2/files and the digest must be SHA1 (max 40 chars), not SHA256 — the old shape was rejected with 400 "File digest missing". Updated the provider, the mock (asserts /v2/files, SHA1 digest, Content-Length) and docs. Netlify's deploy poll default is 120s and the timeout error now reports the deploy's last observed state, so a stuck uploading/processing deploy is diagnosable. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent f6bd62c commit 595bf5a

8 files changed

Lines changed: 30 additions & 21 deletions

File tree

ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ deploy server # dev: local control plane (what the prototype bundles
326326
local|netlify|vercel|cloudflare|s3`, so the same build detection and command
327327
surface ship the same folder to the bundled control plane, the Netlify API
328328
(digest uploads for production, zip for branch deploys), the Vercel API
329-
(sha256 file uploads + manifest), Cloudflare Pages (direct upload), or S3
329+
(SHA1 file uploads + manifest), Cloudflare Pages (direct upload), or S3
330330
(SigV4-signed PutObject). Each provider also handles its own polling until the
331331
host reports ready, with retries/backoff shared via `lib/http.js`. This is how
332332
the CLI can both dogfood its own control plane and target real hosts today;
@@ -370,7 +370,7 @@ Implementation notes:
370370
| Auth (OAuth + keychain) | `deploy login` stores tokens in `~/.deploy-cli/config.json` (env `DEPLOY_CONFIG_DIR` overrides) |
371371
| Blob storage | `storage/` directory, one folder per deploy |
372372
| Streaming tar upload | real POSIX ustar tar, POSTed as a single body |
373-
| Real hosts | provider layer: Netlify digest/zip, Vercel sha-upload, Cloudflare Pages, S3 SigV4 |
373+
| Real hosts | provider layer: Netlify digest/zip, Vercel SHA1 upload, Cloudflare Pages, S3 SigV4 |
374374
| Per-deploy URLs | `http://localhost:8787/<project>/<id>/` |
375375
| Aliases | `latest`, `preview-<branch>` pointers in `registry.json` |
376376
| Rollback | `deploy rollback <id>` re-points `latest` (local); restore/promote on hosts |

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ rollback, and preview deploys. It targets five hosts through one interface:
1111
|-------------|------------------------------------------------------------|-----------------------------------|
1212
| `local` | tar → bundled control plane (`:8787`) | alias re-point (instant) |
1313
| `netlify` | digest uploads (production) / zip (previews) | restore previous deploy |
14-
| `vercel` | sha256 file uploads + files manifest | instant rollback |
14+
| `vercel` | SHA1 file uploads (`/v2/files`) + files manifest | instant rollback |
1515
| `cloudflare`| Pages direct upload (requires `--account`) | dashboard (no API) |
1616
| `s3` | SigV4-signed PutObject (requires `--bucket`) | re-upload (no built-in) |
1717

@@ -68,8 +68,9 @@ Upload strategy notes:
6868
missing files are uploaded — avoids the 25k-file zip cap and 30 s request
6969
timeout). Branch/preview deploys use the zip method (the documented way).
7070
Override with `--method zip|digest`.
71-
- **Vercel** uploads every file once via `POST /v13/files` keyed by sha256, then
72-
creates a deployment referencing the shas (no giant JSON manifest).
71+
- **Vercel** uploads every file once via `POST /v2/files` keyed by SHA1 (the
72+
current documented contract), then creates a deployment referencing the shas
73+
(no giant JSON manifest).
7374
- Both hosts are polled until the deploy is `ready`/`success` by default
7475
(`--no-wait` to skip; `--timeout <seconds>` to bound polling).
7576
- All requests retry network failures and 5xx/429 with exponential backoff;

docs/providers-vercel.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ deploy up --provider vercel
2929

3030
## How uploads work
3131

32-
Every file is uploaded once via `POST /v13/files` keyed by **sha256**
32+
Every file is uploaded once via `POST /v2/files` keyed by **SHA1**
3333
(`x-vercel-digest` header; a 409 means "already uploaded" and is treated as
3434
success). The deployment then references the shas — no giant base64 request
3535
body, and unchanged files between deploys are naturally deduped.

docs/smoke-test.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ A provider without credentials is skipped (exit stays 0). A provider that
4545
| Provider | Upload path | Content check | Rollback check |
4646
|---|---|---|---|
4747
| Netlify | digest upload (sha1 per file) | fetches the returned URL until it serves `smoke-ok` | `POST .../restore` accepted |
48-
| Vercel | sha256 file uploads + manifest | same, against the production alias | undocumented rollback endpoint (the one Vercel's CLI uses) |
48+
| Vercel | SHA1 file uploads (`/v2/files`) + manifest | same, against the production alias | undocumented rollback endpoint (the one Vercel's CLI uses) |
4949
| Cloudflare | Pages direct upload | same, against the `pages.dev` URL | n/a — no rollback API, reported as such |
5050
| S3 | SigV4-signed PutObject | fetches the object via the path-style URL | n/a — plain S3 has no rollback |
5151

lib/providers/netlify.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,17 @@ async function request(pathname, { method = "GET", token, body, headers = {}, qu
5959

6060
/** Poll GET /api/v1/deploys/{id} until state is ready (or error/timeout). */
6161
async function waitForDeploy(token, deployId, timeoutSeconds) {
62-
const deadline = Date.now() + (timeoutSeconds || 60) * 1000;
62+
// 120s default: brand-new auto-created sites can sit in uploading/processing
63+
// while Netlify provisions them, well past the old 60s window.
64+
const deadline = Date.now() + (timeoutSeconds || 120) * 1000;
65+
let lastState = "?";
6366
for (;;) {
6467
const d = await request(`/deploys/${deployId}`, { token, what: "deploy" });
68+
lastState = d.state;
6569
if (d.state === "ready") return d;
6670
if (d.state === "error") throw taggedError(`Netlify deploy ${deployId} failed (state=error)`, { provider: "netlify" });
6771
if (Date.now() > deadline) {
68-
throw taggedError(`Netlify deploy ${deployId} not ready after ${timeoutSeconds || 60}s`, {
72+
throw taggedError(`Netlify deploy ${deployId} not ready after ${timeoutSeconds || 120}s (last state: ${lastState})`, {
6973
provider: "netlify",
7074
hint: "Check the deploy in your Netlify dashboard, or pass --wait=false to skip polling.",
7175
});

lib/providers/vercel.js

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
// Vercel provider. Uses the REST API:
2-
// POST /v13/files upload each file (x-vercel-digest: sha256)
1+
// Vercel provider. Uses the REST API (contract verified 2026-08 against
2+
// vercel.com/docs/rest-api/deployments/upload-deployment-files):
3+
// POST /v2/files upload each file (x-vercel-digest: sha1)
34
// POST /v13/deployments create deployment referencing uploaded shas
45
// GET /v9/projects?name= resolve a project id by name
56
// POST /v9/projects/{id}/rollback/{deploymentId} instant rollback (as the Vercel CLI uses)
@@ -70,22 +71,24 @@ export async function login({ flags }) {
7071
}
7172

7273
/**
73-
* Upload every file once via POST /v13/files, keyed by sha256, and return the
74-
* manifest entries Vercel expects ({ file, sha, size }). Skipping unchanged
75-
* files is handled by treating 409 (already exists) as success.
74+
* Upload every file once via POST /v2/files, keyed by SHA1 (the documented
75+
* digest for the upload API and for deployment file references), and return
76+
* the manifest entries Vercel expects ({ file, sha, size }). A 409 (already
77+
* exists) is treated as success, as is the 200 "file already uploaded" reply.
7678
*/
7779
async function uploadFiles(token, files) {
7880
const manifest = [];
7981
let done = 0;
8082
for (const f of files) {
8183
const content = await fs.promises.readFile(f.path);
82-
const sha = crypto.createHash("sha256").update(content).digest("hex");
83-
const url = new URL(BASE() + "/v13/files");
84+
const sha = crypto.createHash("sha1").update(content).digest("hex");
85+
const url = new URL(BASE() + "/v2/files");
8486
const res = await apiFetch(url, {
8587
method: "POST",
8688
headers: {
8789
Authorization: `Bearer ${token}`,
8890
"Content-Type": "application/octet-stream",
91+
"Content-Length": String(content.length),
8992
"x-vercel-digest": sha,
9093
},
9194
body: content,

site/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ <h2>Ship anywhere</h2>
7777
<div class="provider-row">
7878
<div class="provider"><span class="dot local"></span><strong>local</strong><small>bundled control plane</small></div>
7979
<div class="provider"><span class="dot netlify"></span><strong>netlify</strong><small>digest uploads · restore</small></div>
80-
<div class="provider"><span class="dot vercel"></span><strong>vercel</strong><small>sha256 uploads · rollback</small></div>
80+
<div class="provider"><span class="dot vercel"></span><strong>vercel</strong><small>SHA1 uploads · rollback</small></div>
8181
<div class="provider"><span class="dot cloudflare"></span><strong>cloudflare</strong><small>Pages direct upload</small></div>
8282
<div class="provider"><span class="dot s3"></span><strong>s3</strong><small>SigV4 signed puts</small></div>
8383
</div>

test/providers.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,17 +228,18 @@ const vercelServer = await startServer(async (req, res) => {
228228
if (req.headers.authorization !== "Bearer vc_test") return json(res, 401, { error: { code: "unauthorized" } });
229229
const body = await readBody(req);
230230

231-
if (url.pathname === "/v13/files" && req.method === "POST") {
232-
const digest = req.headers["x-vercel-digest"];
233-
assert.equal(digest, sha256(body));
231+
// current documented contract: POST /v2/files with a SHA1 x-vercel-digest
232+
if (url.pathname === "/v2/files" && req.method === "POST") {
233+
assert.equal(req.headers["x-vercel-digest"], sha1(body));
234+
assert.equal(req.headers["content-length"], String(body.length), "Content-Length sent");
234235
// simulate an already-uploaded file for one of them (409 is fine)
235236
return json(res, body.toString("utf8").includes("rebeccapurple") ? 409 : 200, {});
236237
}
237238
if (url.pathname === "/v13/deployments" && req.method === "POST") {
238239
const payload = JSON.parse(body.toString("utf8"));
239240
assert.equal(payload.name, "sample-site");
240241
const idx = payload.files.find((f) => f.file === "index.html");
241-
assert.equal(idx.sha, sha256(INDEX));
242+
assert.equal(idx.sha, sha1(INDEX));
242243
assert.equal(idx.size, INDEX.length);
243244
assert.ok(!("data" in idx), "manifest references sha, not inline data");
244245
assert.ok(payload.files.some((f) => f.file === "assets/app.css"));

0 commit comments

Comments
 (0)