Releases Index
Any CI
View .md

Publish from any CI

The GitHub Action is one client of a single write: POST /v1/sources/{source}/releases/batch with mode: "upsert-content". GitLab CI, Buildkite, CircleCI, or a docs build can post that same body. There is no second ingest path.

Re-running the same entries is safe. Each release is keyed by a stable URL, and the batch upsert only writes when the body actually changed. An identical re-run returns inserted: 0.

releases publish (in the CLI) will build this body for you — single-file ## sections or one MDX file per entry, --dry-run to print the JSON, --since <sha> to diff. It uses the same planner as the Action. Until that command ships, post the JSON yourself with curl.

1. Create a token

The request needs a Bearer token that can write this source:

  • A publish token (the usual choice). After you verify domain ownership, mint one under Account → Webhooks & API. It can publish to one source and nothing else.
  • A write-scoped machine token, issued by a Releases Index admin.

Read-only user keys (relu_…, including releases login) are rejected. Store the token as RELEASES_API_TOKEN in CI. Treat it like a password.

The token steps, including releases publish-token create, are on Publish from GitHub Actions.

2. Point it at a source

Use the typed source id (src_…). A slug works on the org-scoped route if you also know the organization: POST /v1/orgs/{org}/sources/{slug}/releases/batch.

The first successful write made with a publish token marks the source as push-fed: we stop polling it, because your CI is now how it gets new content. The source page shows "Last Published" instead of "Last Checked".

If the changelog lives in a git repo, say so in releases.json with a push locator. publish is only "push", and it needs both github and path (the file or glob your CI reads):

{
  "github": "acme/docs",
  "path": "changelog/**/*.mdx",
  "publish": "push"
}

We create that source only after the domain ownership claim is verified. An unverified manifest leaves the locator declared and does not flip an existing source to push. path with a * is directory mode (changelog-glob on the Action); a path with no wildcard is one file (changelog-path).

3. POST the batch

curl --fail-with-body --silent --show-error \
  --request POST \
  --header "Authorization: Bearer ${RELEASES_API_TOKEN}" \
  --header "Content-Type: application/json" \
  --url "https://api.releases.sh/v1/sources/${RELEASES_SOURCE}/releases/batch" \
  --data @- <<'JSON'
{
  "mode": "upsert-content",
  "releases": [
    {
      "title": "1.4.0",
      "content": "### Added\n- JSON export",
      "url": "https://example.com/changelog#1.4.0",
      "publishedAt": "2026-05-01T12:00:00Z",
      "version": "1.4.0",
      "type": "feature",
      "prerelease": false
    }
  ]
}
JSON

mode must be "upsert-content". Omitted, the route only fills empty fields and will not update an entry you edited. A typo in mode is a 400.

FieldRequiredNotes
titleyesHeading text. For a date section this is the date line (June 10, 2026).
contentyesMarkdown body under that heading, or the flattened MDX body.
urlyesStable permalink. This is the idempotency key together with the source. Do not put a commit SHA in it.
publishedAtnoISO-8601. A date-only changelog heading is sent as noon UTC (2026-05-01T12:00:00Z).
versionnoSemver or tag, when the entry has one.
typeno"feature" for a versioned entry, "rollup" for a date section.
prereleasenotrue for alpha/beta/rc versions.

A successful response is { "inserted": <n>, "total": <n>, "insertedIds": ["rel_…"] }. inserted counts new rows. An unchanged re-POST still returns 200 with inserted: 0.

Send only the entries that changed. The Action and releases publish do that by diffing since the previous commit (before-sha / --since). The HTTP route does not look at git — it upserts the array you send.

GitLab CI

Variables: RELEASES_API_TOKEN (masked) and RELEASES_SOURCE (src_…). The job below posts one entry. Swap the JSON for the batch your changelog diff produced, or for the file releases publish --dry-run writes once that command is available.

publish-changelog:
  stage: deploy
  image: curlimages/curl:8.11.1
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      changes:
        - CHANGELOG.md
  script:
    - >
      curl --fail-with-body --silent --show-error
      --request POST
      --header "Authorization: Bearer ${RELEASES_API_TOKEN}"
      --header "Content-Type: application/json"
      --url "https://api.releases.sh/v1/sources/${RELEASES_SOURCE}/releases/batch"
      --data '{"mode":"upsert-content","releases":[{"title":"1.4.0","content":"### Added\n- JSON export","url":"https://example.com/changelog#1.4.0","publishedAt":"2026-05-01T12:00:00Z","version":"1.4.0","type":"feature"}]}'

The same curl works on Buildkite, CircleCI, or a script step. Checkout depth does not matter for the HTTP call. It matters only for the client that diffs the changelog (the Action needs fetch-depth: 0; releases publish --since needs the commit you name to be present).

GitHub Actions

On GitHub, use the Action instead of hand-written JSON. It diffs CHANGELOG.md (or a changelog-glob of MDX files) and posts this body for you:

- uses: buildinternet/releases/actions/publish-changelog@main
  with:
    source: src_…
    api-token: ${{ secrets.RELEASES_API_TOKEN }}

Inputs, directory mode, and URL templates: Publish from GitHub Actions.

What the server does with the batch

The write is the same one ingest already uses. A new row gets content generation, embeddings, live events, and web revalidation. Deleting a file from the repo does not delete the release — that stays a curator action.