Continuous documentation for Leed: schedule the documentation and content pages tagged with the commits in a release to publish at one time, so your docs ship with your code. Authenticates with a GitHub OIDC token. Nothing long-lived is stored in your repository.
name: Schedule Leed content release
on:
push:
tags: ["v*"]
permissions:
id-token: write # required — see below
contents: read
jobs:
schedule:
runs-on: ubuntu-latest
environment: leed-release
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- uses: leed-ai/schedule-release@v1
with:
audience: leed-cms:3f2a9c14-8b7e-4d61-9a03-1c5e77b2d840
publish-at: "2026-10-01T09:00:00Z"
tag-pattern: "v*" # optional; skips nightly/build tags between releases
dry-run: "false"There is no since. On a tag build the action schedules everything in this tag that was not
in the previous matching tag — which is what a release means. On a branch push it uses the ref's
previous value. You override it only if your process is unusual.
Register a CI trust in Leed: Settings → CI trust. Choose the GitHub Actions profile, set
the issuer to https://token.actions.githubusercontent.com, pin your repository and a ref or
deployment environment, tick the page types the pipeline may publish, and choose an editorial
ceiling.
The repository is pinned by its numeric id, not by owner/repo. Read yours with:
gh api repos/OWNER/REPO --jq .idAn id is stable across renames and transfers, and — unlike a path, which someone else can claim once you free it — is never re-issued to a different repository. The form also asks for the repository name; that is a label for the trust list only. It is never matched, so renaming the repository leaves the label stale and the trust working.
Leed then shows you a generated audience of the form leed-cms:<uuid>. Copy it into
audience: above. You do not choose it, and there is no field for it — a value you pick is a
value another customer can pick too, and GitHub's issuer is identical for everyone.
| Input | Required | Default | |
|---|---|---|---|
audience |
yes | The audience Leed generated for your trust. Matched byte-for-byte. Not a secret. | |
publish-at |
no | 15 minutes after the job starts | ISO 8601. A time already past is accepted, and those pages publish on Leed's next publish run. See below before setting a time close to now. |
since |
no | (auto) | Override the auto-detected start point. |
tag-pattern |
no | * |
Which tags count as releases, e.g. v*. Tag builds only. |
merge-commit |
no | A merge commit whose ^1..^2 is the release. See below. |
|
source-ids |
* | An explicit list instead of a commit range. See below. | |
until |
no | HEAD |
Commit or ref the release ends at. |
minimum-status |
no | (inherit) | draft, in_revision, approved. Omitted, the trust's ceiling applies. A stricter value narrows it; a looser one is refused. |
dry-run |
no | true |
true reports without changing anything. A dry run always runs first regardless. |
leed-url |
no | https://app.leed.ai |
Base URL of your Leed instance. |
fail-on-unmatched |
no | false |
Fail if any commit matched no page. Usually undesirable — most commits touch code, not content. |
In the common cases, you configure nothing. The action works it out from the CI context:
| Situation | What it uses | |
|---|---|---|
Tag build (GITHUB_REF_TYPE=tag) |
previous matching tag → this tag | The release workflow. Set tag-pattern: "v*" if non-release tags sit between releases. |
| First tag ever | all history up to the tag | There is no earlier release, so the whole history is the release. |
| Branch push | github.event.before → HEAD |
Everything this push added, including a merge commit if one is in it. |
Merge to a branch, no usable before |
the merge's ^1..^2 |
For "merging to main IS the release" — on a re-run, a manual dispatch, or a ref's first pipeline. |
| First push to a new ref, no merge at HEAD | refuses | Nothing to diff against; pass since, merge-commit or source-ids. |
Override with since/until when your process does not fit, or bypass ranges entirely with
source-ids.
since/until go straight to git log "<since>..<until>" --format=%H — git's two-dot range:
commits reachable from until but not from since.
sinceis exclusive — the boundary you start after. Its own commit is not included, which is what you want when it is the previous release tag.untilis inclusive, and defaults toHEAD.
On linear history that reads as "everything after the last release, up to now". With merges it is a set difference rather than a straight line, so a commit brought in by merging a long-lived branch lands in whichever release first made it reachable — usually what you want, occasionally surprising.
A caution if you do set since by hand: do not use github.event.before on a tag push. A
newly created tag has no previous value, so that context is the all-zero SHA. The action detects
that specific value and says so, rather than leaving you hunting a fetch problem you do not have.
If your release is "we merged a branch to main", the action already does the right thing on a
push — github.event.before → HEAD covers everything the merge brought in (plus the merge
commit itself, which matches no page and is reported as one unmatched id).
merge-commit exists for when there is no usable "previous value": re-running a past release,
scheduling from a workflow_dispatch, or the first pipeline on a ref. Given a merge sha it uses
<sha>^1..<sha>^2 — the commits that merge brought in, without the merge commit — and the action
falls back to the merge at HEAD automatically when before is missing or all-zero.
on:
workflow_dispatch:
inputs:
merge: { description: "Merge commit to release", required: true }
# ...
- uses: leed-ai/schedule-release@v1
with:
audience: leed-cms:3f2a9c14-…
merge-commit: ${{ inputs.merge }}
publish-at: "2026-10-01T09:00:00Z"Squash and fast-forward merges produce no merge commit, so merge-commit refuses them and
says so. For those, the squashed commit is the release — use since with the commit before it,
or pass source-ids.
If a release tool, changelog generator or manifest already knows which commits ship, hand them over directly and skip the range entirely:
- id: release
run: echo "ids=$(my-release-tool --commits)" >> "$GITHUB_OUTPUT"
- uses: leed-ai/schedule-release@v1
with:
audience: leed-cms:3f2a9c14-…
source-ids: ${{ steps.release.outputs.ids }}
publish-at: "2026-10-01T09:00:00Z"Format: separate with newlines, commas, or both. Surrounding whitespace is trimmed, blank
entries dropped, duplicates removed. So a git log capture, a file read with $(cat ids.txt),
and a comma list all work without reshaping.
A source id is an arbitrary string. It is usually a commit SHA, but Leed does not require
one — release-2026-10-01 is a perfectly good id if that is what tagged the pages. Nothing here
validates the shape, precisely so that stays true.
Supplying source-ids takes precedence over since/until, and skips git entirely — so it also
works in a shallow clone, where the range path deliberately refuses to run.
scheduled, would-schedule, matched, unmatched-source-ids (JSON array).
permissions: id-token: write. Without it the runner exposes no OIDC variables at all. The
action checks first and says so, instead of failing later on an unset variable. Note that
declaring id-token narrows every other permission to none, which is why contents: read is
spelled out for the checkout.
fetch-depth: 0. A shallow clone makes git log return an empty range — so the job would
succeed, report zero pages, and silently skip your entire release. The action refuses to run in
a shallow clone. Add fetch-tags: true if since is a tag.
Pin the environment, not the ref, for tag-triggered jobs. GitHub's ref claim on a tag push
is refs/tags/v1.2.3 — different every release, so no fixed ref constraint can match twice.
A deployment environment is stable. If your job runs on a branch instead, pin
refs/heads/main (GitHub sends the long form) and drop environment:.
Do not name the environment production unless you mean it. Declaring an environment
subjects the job to its deployment protection rules; a real production environment often
carries required reviewers, and the release job will sit waiting for approval. A dedicated
environment with no protection rules keeps the claim stable without inheriting a gate.
The audience goes in the query string with &, not ?. The action does this correctly. If
you ever hand-roll it: ACTIONS_ID_TOKEN_REQUEST_URL already carries ?api-version=…, and using
? yields a token with GitHub's default audience — the organization URL, shared by every
workflow in it — which Leed rejects.
@actions/core.getIDToken() is more ergonomic than the curl, but a JS action carries a bundled
runtime that GitHub deprecates on its own schedule, independent of anything here. A composite
action has no build step and nothing to age out. The one footgun getIDToken would remove — the
&/? mistake — is removed anyway, because the action makes the request for you.
- A dry run always runs first, on every chunk, in both modes.
- Releases with more than 1000 commits are split across calls, all sharing one
publish-at. - Leave
publish-atunset unless you need a specific time. Leed starts building pages a few minutes before their publish time, and scheduling goes one page at a time, so a time only a few minutes away can let publishing begin while the release is still being scheduled; the early pages then go out separately from the rest. The default, 15 minutes after the job starts, leaves room for releases of several hundred pages. - Leed caps pages per call and reports
remaining; the action repeats the confirmed request until the remainder is drained. A page that can never be scheduled (a PUBLISHED one needing a revise, say) still consumes a slot, so the action stops with an explanatory error rather than looping when a call makes no progress. - Per-page failures (a page below the trust's minimum status, a page type the trust doesn't grant, a missing required field) are printed with their reasons, one ✗ line per page, and do not fail the job: the rest of the release is still scheduled, so read the ✗ lines. The job fails when Leed refuses the request itself (HTTP 401, 402 or 403, with the likely cause printed), when a confirmed call schedules nothing while pages remain, or when
fail-on-unmatchedis set and a commit in the range matched no page.
Every release takes four steps. Steps 3 and 4 are the ones that get forgotten.
- Merge through a pull request.
mainrejects direct pushes and requires both test jobs (Ubuntu and macOS) to pass. - Tag the merge commit with the full version, e.g.
v1.0.3. The "Protect release tags" ruleset restricts tag creation, so only an account with bypass can push it. - Move the major tag to the same commit, so everyone pinned to
@v1gets the update:Moving a tag is an update under the same ruleset, so it also needs an account with bypass.git tag -f v1 && git push -f origin v1 - Create the GitHub release and tick "Publish this Action to the GitHub Marketplace". Publishing is per release: a release without the box ticked does not update the Marketplace listing, which keeps showing the last release that had it. The categories carry over.
Keep in mind:
- The
descriptioninaction.ymlmust be under 125 characters, or the Marketplace refuses the release. - A breaking change (a removed or renamed input, a changed default) gets
v2.0.0and a newv2tag. Leavev1where it is so workflows pinned to@v1keep working. - Add an entry to
CHANGELOG.mdfor each version and use it as the release notes.
MIT © Leed, Inc. Using this does not require a Leed license; scheduling content requires a Leed account on a plan that includes it.