Syndroo docs
Version 0.2.0-rc.1 unpublished release candidate Website GitHub

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.

This publishes for real.

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_URL and SYNDROO_API_KEY as pipeline secrets. The key can publish, so it needs the same handling as a password.
  • GIT_COMMIT or 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 fetch and JSON.

Steps

  1. Create the script

    Save this as publish.mjs next 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);
  2. What the script guarantees

    • The body is built as JSON, not as text. JSON.stringify escapes 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, 503 and 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 id fails the job rather than polling a guessed URL.
    • Only the top-level status drives the loop. A publication that is already published or failed never 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.
  3. 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.mjs

    A repeated run of the same commit sends the same key and the same body, so the API answers with the original post and replayed: true instead 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

SituationExitCorrect 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.