Auto-post release announcements to social + community channels from your GitHub Actions release workflow. Every post gets a Pluck-signed receipt so visitors at
broadcast.sizls.com/broadcastcan verify the post happened, exactly as shown, months later.Roadmap: Rekor anchoring of the cassette envelope is planned (the
PostsIndexRow.rekorUrlfield ships asnulltoday; wiring the anchor lands the third-party-witness leg). The signed cassette + client-side ed25519 verify path is already live.
This is the mirror of @sizls/broadcast-action — the source lives in the private sizls/broadcast monorepo and is published here on every release as a bundled dist/index.js you can pin by SHA. Every release is signed via Sigstore using GitHub Actions keyless OIDC, so you can verify the binary you're running is the binary the monorepo produced.
If you're setting up broadcast for the first time, use the reusable workflow — smaller config surface, npm-provenance-verified CLI, easier upgrades, no bundled JavaScript action to trust byte-for-byte:
# .github/workflows/release.yml
name: Broadcast
on:
release:
types: [published]
jobs:
broadcast:
uses: sizls/broadcast-action/.github/workflows/broadcast-reusable.yml@<sha>
with:
platforms: bluesky,mastodon,discord,linkedin,twitter,facebook
cli-version: "0.2" # minor-floating; pass "0.2.1" for exact-pin
secrets: inheritSecrets to set on the caller repo (Settings → Secrets and variables → Actions):
BROADCAST_API_KEY— SaaS-path bearer token for your tenant onbroadcast.sizls.com. Skip if you're on the legacy self-host path withPOSTS_INDEX_SECRET.BLUESKY_APP_PASSWORD— Bluesky auth inidentifier:appPasswordformat. Missing → adapter silently skips.MASTODON_TOKEN— Mastodon OAuth app token withwrite:statuses. Missing → adapter silently skips.DISCORD_WEBHOOK_URL— Discord channel webhook URL. Missing → adapter silently skips.LINKEDIN_ACCESS_TOKEN— LinkedIn Marketing Developer Platform token withw_member_social(personal) orw_organization_social(page). Rotates ~every 60 days. Missing → adapter silently skips.TWITTER_BEARER_TOKEN— X API v2 bearer token from a paid tier (Basic $200/mo minimum; free tier is read-only). Pay-per-usage on top: ~$0.015/post, ~$0.20/post-with-URL. Missing → adapter silently skips.FACEBOOK_PAGE_TOKEN— long-lived Facebook Page access token withpages_manage_posts. Derive viaGET /oauth/access_token?grant_type=fb_exchange_tokenthenGET /{page-id}?fields=access_token. Missing → adapter silently skips.
Config fields to add to .sizl/broadcast.config.json when wiring the LinkedIn / Facebook adapters (Twitter takes no extra config — the bearer token is enough):
linkedinActorUrn—urn:li:person:<id>orurn:li:organization:<id>depending on the token scope.facebookPageId— numeric Page ID from Meta's Graph API Explorer.
Instagram was in the v0.2.0 launch set but was pulled in v0.2.1 pending a media pipeline (Instagram Business content publishing requires an image, and the release-payload path doesn't have one yet). It's queued for v0.3.
Config file: create .sizl/broadcast.config.json in your repo (see the example config); override the path with the config-path input.
Test locally: run npx @sizls/broadcast-cli doctor to verify env-var coverage. Pass dry-run: true to the workflow to validate + sign without posting.
See .github/workflows/broadcast-reusable.yml
in this repo for the input schema and the trust-chain notes.
Every invocation runs npm audit signatures @sizls/broadcast-cli
before it reaches the CLI, so a hostile republish under a squatted
maintainer account can't clear the gate.
The reusable workflow caps the broadcast job at 15 minutes by
default. That ceiling covers the v0.2 launch set (Bluesky + Mastodon +
Discord + LinkedIn + Twitter/X + Facebook) with room for one adapter
to retry, plus the ~30 s npm audit signatures provenance check and
the npx --yes cold tarball fetch on a fresh runner. Consumers who
fan out to a larger adapter set — the newsletter family, Slack,
Reddit — can raise the ceiling via the timeout-minutes input:
jobs:
broadcast:
uses: sizls/broadcast-action/.github/workflows/broadcast-reusable.yml@<sha>
with:
platforms: bluesky,mastodon,discord,linkedin,twitter,facebook,mailchimp,buttondown,beehiiv
timeout-minutes: 30
secrets: inheritThe upstream GitHub-Actions workflow ceiling is 6 h, so any positive
integer up to ~360 is valid. The default is deliberately tight — a
run stuck past 15 minutes on the launch set almost always means an
adapter is wedged and the job should be killed, not extended. The
one other case worth raising it for is when npm's registry is
degraded and the npm audit signatures provenance check runs long
against its 120 s per-lookup cap.
Everything below is the legacy bundled path — it still works and
existing consumers pinned to sizls/broadcast-action@<sha> don't
need to migrate. If you're on this path today, the reusable
workflow above is the recommended shape for new setups but not a
hard requirement.
Add to your release workflow. The SaaS path at broadcast.sizls.com
is the shortest install — one API key, a handful of platform tokens
for whichever adapters you want to enable, done:
# .github/workflows/release.yml
on:
release:
types: [published]
jobs:
broadcast:
runs-on: ubuntu-latest
steps:
- uses: sizls/broadcast-action@<sha> # pin by SHA, not tag
with:
platforms: bluesky,mastodon,discord,linkedin,twitter,facebook
env:
BROADCAST_API_KEY: ${{ secrets.BROADCAST_API_KEY }}
BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }}
MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
LINKEDIN_ACCESS_TOKEN: ${{ secrets.LINKEDIN_ACCESS_TOKEN }}
TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN }}
FACEBOOK_PAGE_TOKEN: ${{ secrets.FACEBOOK_PAGE_TOKEN }}Get a BROADCAST_API_KEY by emailing hi@sizls.com during the
private beta. Every post comes back with a public receipt URL at
broadcast.sizls.com/r/<token> that anyone can open to cryptographically
verify the post in their own browser — no signup, no SDK, just
WebCrypto against the inlined cassette envelope.
If you run your own posts-index Worker (deployed via
pnpm create broadcast-worker on your own Cloudflare account), skip
BROADCAST_API_KEY and use the HMAC inputs instead:
- uses: sizls/broadcast-action@<sha>
with:
platforms: bluesky,mastodon,discord,linkedin,twitter,facebook
posts-index-url: https://your-worker.example.com/posts
posts-index-secret: ${{ secrets.POSTS_INDEX_SECRET }}
env:
BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }}
MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
LINKEDIN_ACCESS_TOKEN: ${{ secrets.LINKEDIN_ACCESS_TOKEN }}
TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN }}
FACEBOOK_PAGE_TOKEN: ${{ secrets.FACEBOOK_PAGE_TOKEN }}When BROADCAST_API_KEY is unset the action falls back to this path
verbatim — pre-existing consumers don't need to migrate.
When a GitHub release is published in your project, this Action:
- Translates the release payload into the broadcast runtime's
AnnounceableEventshape. - Resolves the tier for the event from your
.sizl/broadcast.config.json(PATCH releases default to Tier 1, MINOR / MAJOR / RECAP / MANUAL default to Tier 2). - Refuses Tier 2/3 events with a structured error — the GitHub Actions runtime is ephemeral and cannot host the 15-minute Tier 2 approval window. The refusal message points you at the Cloudflare Worker template (
pnpm create broadcast-worker) which CAN. - For Tier 1 events, runs the broadcaster's safety floor (cost circuit breaker, internal-token sanitizer, kill switch, dry-run mode) and dispatches the post.
- After each successful post, POSTs the cassette envelope (Pluck-signed under the tenant's managed ed25519 keypair; the
rekorUrlfield is placeholder until Sigstore Rekor anchoring wires up) to the Sizl-hosted posts-index atbroadcast.sizls.com/posts. That endpoint feeds thebroadcast.sizls.com/broadcastkill-log page and the long-running engagement collector.
| Input | Required | Default | Description |
|---|---|---|---|
platforms |
yes | — | Comma-separated platform IDs. Launch set as of v0.2.1: bluesky, mastodon, discord, linkedin, twitter, facebook. Worker-only (self-hosted template): slack, threads, reddit, mailchimp, convertkit, beehiiv, buttondown, ghost, mailerlite, substack. Instagram was in the v0.2.0 set and is queued to return in v0.3 behind a media pipeline. |
config-path |
no | .sizl/broadcast.config.json |
Path to your broadcast config JSON file inside the repo. The Action loads this via JSON.parse; .ts configs are not supported. |
dry-run |
no | false |
When true, validates + sanitizes + signs but doesn't post. |
posts-index-url |
no | https://broadcast.sizls.com/posts |
Sizl-hosted posts-index URL to POST cassette envelopes to. |
posts-index-secret |
no | — | HMAC secret for the posts-index POST. Skipped on dry-run. |
| Output | Description |
|---|---|
cassette-hashes |
JSON array of {platform, cassetteHash, postId} entries — one per posted platform. |
skipped |
JSON array of {platform, reason} entries for platforms that were skipped (kill-switch, sanitize, validation, idempotency). |
receipt-urls |
JSON array of {platform, receiptUrl} entries returned by the SaaS path (broadcast.sizls.com/r/<token> — one per accepted entry). Empty on the legacy HMAC path; receipts there resolve through the kill-log page at the posts-index host. |
The SaaS Worker returns each entry as either accepted or rejected. Consumers writing custom retry logic can import { REJECT_REASONS, type RejectReason } from "@sizls/broadcast-posts-index" and pattern-match against the stable enum. The four failure classes:
| Class | Codes | Retry class |
|---|---|---|
| Shape validation | entry-not-object, invalid-platform, invalid-postId, invalid-postUrl, missing-text, text-exceeds-server-cap, invalid-text, invalid-eventId, invalid-briefHash, missing-approval, invalid-approval-tier, invalid-approvedBy, invalid-approvedAt, invalid-draftsConsidered |
Terminal — caller-side payload bug; fix and retry. |
| Batch bounds | too-many-entries, quota-exhausted-in-batch |
Terminal — split the batch or wait for the next quota window. |
| Signer-side | signer-unavailable, sign-failed |
Retryable — server transient; exponential backoff. |
| Signer contract | sign-no-cassette |
Investigate — signer succeeded without a cassette; report don't retry. |
| Persist | persist-failed |
Retryable — KV write transient; exponential backoff. |
The invalid-text reason additionally carries a detail string with the byte offset + Unicode code point of the first rejected control character — safe to surface in operator-facing logs.
Reject reasons above live inside response.rejected[].reason — one per entry that failed. A separate class of error terminates the whole request before any entry is processed and appears as response.error at HTTP 4xx/5xx:
| HTTP | error |
Retry class |
|---|---|---|
| 400 | malformed-json |
Terminal — caller bug; fix the JSON. |
| 400 | no-entries |
Terminal — send at least one entry. |
| 400 | too-many-entries |
Terminal — split the batch (cap is 32). |
| 413 | body-too-large |
Terminal — split the batch (cap is 200 KiB). |
| 429 | monthly-cap-reached |
Wait — monthly usage counter hit the tier cap. |
| 429 | rate-limit-exceeded |
Retryable with backoff — burst limit (60/min); back off and retry. |
| 500 | signer-misconfigured |
Investigate — server-side configuration issue; report don't retry. |
Every release of this Action is signed with Sigstore keyless OIDC tied to the source workflow in sizls/broadcast. Before pinning a new SHA, run:
cosign verify-blob \
--certificate-identity-regexp "https://github.com/sizls/broadcast/\\.github/workflows/release-action\\.yml@.+" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--signature dist/index.js.sig \
dist/index.jsThe certificate-identity regex anchors the signature to the source workflow path — even if an attacker compromises the public mirror repo and tries to push an unsigned binary, cosign verify-blob will fail.
Always pin by SHA, not tag. Tags can be retargeted. SHAs cannot. The pinned-SHA pattern matches industry norms for production GitHub Actions (see dependabot and renovate defaults).
# good
- uses: sizls/broadcast-action@a1b2c3d4...
# avoid
- uses: sizls/broadcast-action@v1
- uses: sizls/broadcast-action@mainThe source — adapter network, cost-breaker logic, sanitizer rules — lives in the private sizls/broadcast monorepo because the moat is the Sizl-curated adapter + Bureau-signing surface, not any single bundle. This public mirror exists because GitHub Actions resolves uses: <repo>@<sha> against a public repository path. Mirroring the bundled artifact here (and ONLY the artifact, no source) is the canonical pattern that keeps the source posture private while letting the Action be consumed externally.
directive.run/docs/broadcast/action— full referencedirective.run/docs/broadcast/worker— Cloudflare Worker template (Tier 2/3 events)directive.run/docs/broadcast/cli—@sizls/broadcastCLI for manual posts + backfillsbroadcast.sizls.com/broadcast— public Pluck-verifiable kill log
Apache-2.0 — see LICENSE.md.
The source remains under a separate license inside the private monorepo. This mirror's license applies only to the published bundle.
Found a security issue? Don't open a public issue — email hi@sizls.com or use GitHub's private vulnerability reporting.