Recipe: call Syndroo from CI or a script
A pipeline step publishes one post and then waits for the real per-platform outcome, instead of treating an accepted request as a finished job. The key is derived from the change, so a repeated run with the same key and the same body is answered from the stored result instead of publishing again.
The script below sends a real post to the platforms you configure. Run it against your own Worker first, and keep the API key in the pipeline's secret store: pass it through the environment, never on the command line, and never with shell tracing enabled.
Inputs
SYNDROO_URLandSYNDROO_API_KEYas pipeline secrets. The key can publish, so it needs the same handling as a password.GIT_COMMITor another reproducible identifier. It becomes the idempotency key, so the same logical post always produces the same key. The API accepts 1-128 characters from letters, digits, dot, underscore, colon and hyphen.- The text, already assembled by the pipeline and inside each platform's limit.
- A decision for
partial: the script fails the job by default, and a person decides what happens next. - Node.js 22 or newer - the same floor as the Worker - and network access to your deployment. No packages to install: the script uses
fetchandJSON.
Steps
-
Create the script
Save this as
publish.mjsnext to your pipeline configuration.// Node.js 22 or newer, no dependencies. // Environment: SYNDROO_URL, SYNDROO_API_KEY, GIT_COMMIT (optional POLL_INTERVAL_MS). const { SYNDROO_URL: base, SYNDROO_API_KEY: apiKey, GIT_COMMIT: commit } = process.env; const KEY_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; const REQUEST_TIMEOUT_MS = 15000; const MAX_POLLS = 10; if (!base || !apiKey) { console.error("SYNDROO_URL and SYNDROO_API_KEY must be set"); process.exit(2); } if (!commit) { console.error("GIT_COMMIT must be set"); process.exit(2); } // Validate the value that is actually sent as the idempotency key. const postKey = `deploy-${commit}`; if (!KEY_PATTERN.test(postKey)) { console.error("the idempotency key must be 1-128 characters of letters, digits, dot, underscore, colon or hyphen"); process.exit(2); } const intervalMs = Number(process.env.POLL_INTERVAL_MS ?? 15000); if (!Number.isInteger(intervalMs) || intervalMs < 0 || intervalMs > 60000) { console.error("POLL_INTERVAL_MS must be an integer between 0 and 60000"); process.exit(2); } const terminal = new Set(["published", "partial", "failed"]); const waiting = new Set(["queued", "scheduled", "publishing"]); // Diagnostics stay bounded: a status and an error code, never a response body, // which can echo request content straight back into a CI log. function describe(status, json) { const code = json?.error?.code; const safe = typeof code === "string" && /^[A-Z][A-Z0-9_]{0,63}$/.test(code) ? code : "no error code"; return `HTTP ${status} (${safe})`; } async function requestJson(url, init) { let response; try { response = await fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); } catch (error) { console.error(`request ended without a response (${error?.name ?? "network error"}); nothing was resubmitted`); process.exit(2); } let json = null; try { json = JSON.parse(await response.text()); } catch { json = null; } return { response, json }; } // Only the post's own status and each publication's own status are read here. function printResult(post) { console.log(`post status: ${post.status}`); for (const publication of post.publications ?? []) { const code = publication.errorCode ?? "-"; const attempts = publication.attempts ?? "?"; console.log(` ${publication.platform} ${publication.status} ${code} attempts ${attempts} ambiguous ${publication.errorAmbiguous ?? false}`); } } const accepted = await requestJson(`${base}/v1/posts`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": postKey, }, body: JSON.stringify({ content: `Deploy ${commit} is live: queue retries now respect an explicit deadline.`, platforms: ["bluesky"], }), }); if (!accepted.response.ok) { console.error(`POST rejected: ${describe(accepted.response.status, accepted.json)}`); process.exit(2); } const postId = accepted.json?.id; if (typeof postId !== "string" || postId.length === 0) { console.error("POST answered without a usable post id; not polling"); process.exit(2); } console.log(`accepted post: ${postId}`); for (let attempt = 1; attempt <= MAX_POLLS; attempt += 1) { const poll = await requestJson(`${base}/v1/posts/${encodeURIComponent(postId)}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); if (!poll.response.ok) { console.error(`GET failed: ${describe(poll.response.status, poll.json)}`); process.exit(2); } const status = typeof poll.json?.status === "string" ? poll.json.status : null; if (status === null) { console.error("GET response carried no usable status; stopping instead of polling blindly"); process.exit(2); } if (terminal.has(status)) { printResult(poll.json); process.exit(status === "published" ? 0 : 1); } if (!waiting.has(status)) { console.error("GET response carried an unrecognised status; stopping without echoing it"); process.exit(2); } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } console.error(`still waiting after ${MAX_POLLS} checks: ${base}/v1/posts/${postId}`); console.error("not resubmitting automatically; check the post before sending anything again"); process.exit(1); -
What the script guarantees
- The body is built as JSON, not as text.
JSON.stringifyescapes the commit id, so a quote or a backslash in the environment cannot break the request. - The final key and the poll interval are validated before anything is sent. An unusable key or interval exits with code 2 without contacting the Worker.
- Every request has a deadline and log lines stay bounded. Requests time out after 15 seconds, and failures print an HTTP status and an error code rather than a response body, which could echo request content into the log.
- Non-2xx answers stop the run.
401,409,503and every other failure exit with code 2; a request that ends without a response also stops the run and says nothing was resubmitted. - No usable post id, no polling. A response without a non-empty
idfails the job rather than polling a guessed URL. - Only the top-level status drives the loop. A publication that is already
publishedorfailednever stands in for the post's own status, and an unparsable or unrecognised status stops the run instead of polling blindly. - Per-platform results are printed. The job log says which platform failed, with its error code, attempt count and ambiguity flag.
- Nothing is resubmitted. If the poll budget runs out, the script exits non-zero and says so.
- The body is built as JSON, not as text.
-
Run it from the pipeline
export SYNDROO_URL="https://your-worker.your-subdomain.workers.dev" export SYNDROO_API_KEY="$SYNDROO_API_KEY_SECRET" export GIT_COMMIT="$GIT_COMMIT" node publish.mjsA repeated run of the same commit sends the same key and the same body, so the API answers with the original post and
replayed: trueinstead of publishing a second time.
Expected output
accepted post: post_01J9ZK5D8R
post status: published
bluesky published - attempts 1 ambiguous false
accepted post: post_01J9ZK9F1B
post status: partial
bluesky published - attempts 1 ambiguous false
threads failed RATE_LIMIT attempts 3 ambiguous false
Failure handling
| Situation | Exit | Correct behaviour in a pipeline |
|---|---|---|
HTTP 202 with status: queued |
continues | Keep the id and poll. Acceptance is not delivery. |
HTTP 200 with replayed: true |
continues | The pipeline retried an identical request; the original post is reused. |
HTTP 401 UNAUTHORIZED |
2 | Stop. Fix the secret. Never loop on authentication failures. |
HTTP 409 IDEMPOTENCY_CONFLICT |
2 | The key already belongs to different text. Decide by hand whether this is the same post; use a new key only for genuinely new content. |
HTTP 503 SERVICE_UNAVAILABLE |
2 | Maintenance mode is refusing new posts. Retry later with the same key and the same body; reads keep working. |
Post status partial |
1 | Fail the job and report per platform. There is no retry endpoint: do not create a new request or a new key that targets the platforms which already published, because that would post again. Re-sending the identical key and body is safe and returns the stored result. |
errorAmbiguous: true on a publication |
1 | Never auto-retry. Mark the job as needing a person and check the platform by hand. |
| Poll budget exhausted | 1 | Report the post URL and stop. Do not resubmit; a slow publication is still a publication. |
Applies to
Syndroo 0.2.0-rc.1 (unpublished candidate). The script uses only the documented API: create a post, check a post, and the idempotency rules. It was exercised against a local stub in this repository's test suite, not against a live platform.