Keep the docs in another repository

Take code examples from the code’s repository when the docs have a repository of their own, pinned to a commit, and keep them up to date with a pull request.

For AI agents: the documentation index is at llms.txt, and this page is available as Markdown.

When the docs have a repository of their own, a source can name the code’s repository instead of a folder. Pages address it the same way as a folder beside the docs, so moving the code from one repository to the other changes ascribe.toml and no page.

Ascribe doesn’t read the other repository whenever it checks. It copies the files your snippets use, whole, into sources/<name>/ beside ascribe.toml, and pins the commit they came from in ascribe.lock. You commit both with the docs.

Take examples from the code’s repository

  1. Name the repository in ascribe.toml:

    [sources.api]
    git = "https://github.com/acme/api.git"
    branch = "main"                           # the branch `update` follows
    include = ["src/**", "examples/**"]
  2. Write the @snippet, then run ascribe sources fetch. It pins the source to the head of its branch the first time, and copies the file.

  3. Commit ascribe.lock and sources/.

  4. When the code moves on, run ascribe sources update, or let the update pull request run it. It moves the pin, copies the files again, and lists the pages whose examples changed, the way ascribe drift does, so the pull request that moves the pin says which pages to read. The pages are found by comparing with the docs’ last commit, so in a new repository, commit once first.

Everything else reads the copies: ascribe check, ascribe build, the editor, and ascribe diff and ascribe drift, which read the copies at the base from the docs repository’s own history. None of them run git against the code’s repository or use the network, and ascribe check fails on a copy edited by hand, since the change belongs in the code. ascribe sources status shows each pin and copy.

  • Tested examples. When the code’s repository has an ascribe.examples.toml, fetch and update copy it and its observations too, whatever include says, and the pages say what each example was tested with. They’re read as text: nothing in them is run here.

  • What’s published. The copies are committed with the docs, so everyone who can read the docs repository can read them. When the code is private and the docs are public, that’s a decision to make knowingly; fetch and update say so the first time they copy a source’s files.

  • Access. ascribe sources fetch and update run git, with whatever credentials let git fetch work in that shell. A CI job that only checks or builds needs no access to the code’s repository, and neither does the host that builds your site. A job that runs update needs read access, for a private repository a token from a GitHub App or a read-only deploy key, given to git as any other job would, for example:

    git config --global url."https://x-access-token:${CODE_TOKEN}@github.com/".insteadOf "https://github.com/"

The update pull request

Nothing moves a pin unless something runs ascribe sources update. This GitHub Actions workflow runs it on a schedule, in the docs repository, and when an example changed, opens a pull request that moves the pins and updates the copies. There’s one at a time: while it’s open, each run updates it in place, on the branch ascribe/update-sources.

name: Update sources

on:
  schedule:
    - cron: "17 6 * * 1-5" # weekdays, 06:17 UTC
  workflow_dispatch:

permissions:
  contents: read

# One run at a time, so two never push the branch at once.
concurrency:
  group: update-sources

jobs:
  update:
    runs-on: ubuntu-latest
    env:
      BRANCH: ascribe/update-sources
    steps:
      # A token for this repository, from a GitHub App, to push the branch and
      # open the pull request. With the default token, the pull request's own
      # checks wait for someone to approve them.
      - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
        id: docs
        with:
          client-id: ${{ vars.SOURCES_APP_CLIENT_ID }}
          private-key: ${{ secrets.SOURCES_APP_PRIVATE_KEY }}
          permission-contents: write
          permission-pull-requests: write
      # A token that can only read the code's repositories.
      - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
        id: code
        with:
          client-id: ${{ vars.SOURCES_APP_CLIENT_ID }}
          private-key: ${{ secrets.SOURCES_APP_PRIVATE_KEY }}
          repositories: sources-fixture-code # your code's repositories, comma-separated
          permission-contents: read
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          token: ${{ steps.docs.outputs.token }}
      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 24
      - run: npm install --global @ascribed/cli
      - name: Move the pins
        env:
          # The code token, for `git` in this step only.
          GIT_CONFIG_COUNT: 1
          GIT_CONFIG_KEY_0: url.https://x-access-token:${{ steps.code.outputs.token }}@github.com/.insteadOf
          GIT_CONFIG_VALUE_0: https://github.com/
        run: |
          ascribe sources status --format json > "$RUNNER_TEMP/before.json"
          ascribe sources update --format markdown > "$RUNNER_TEMP/update.md"
      - name: Open or update the pull request
        env:
          GH_TOKEN: ${{ steps.docs.outputs.token }}
          BOT: ${{ steps.docs.outputs.app-slug }}[bot]
        run: |
          # The open pull request from this repository's own branch, not a fork's.
          open=$(gh pr list --head "$BRANCH" --state open --json number,isCrossRepository \
            --jq 'map(select(.isCrossRepository | not)) | .[0].number // empty')
          if [ -n "$open" ] && git fetch --quiet --depth=2 origin "$BRANCH"; then
            # Someone pushed to it, to fix a page: leave it alone.
            if [ "$(git log -1 --format=%an FETCH_HEAD)" != "$BOT" ]; then
              echo "$BRANCH has commits from someone else, so it's left as it is. Merge or close its pull request to get updates again." >> "$GITHUB_STEP_SUMMARY"
              exit 0
            fi
            pushed=$(git log -1 --format='%T %P' FETCH_HEAD)
          fi
          # No example changed (a pin may have moved): nothing to read.
          if [ -z "$(git status --porcelain -- sources)" ]; then
            echo "No example changed." >> "$GITHUB_STEP_SUMMARY"
            if [ -n "$open" ]; then
              gh pr close "$open" --delete-branch --comment "The examples are back to what the default branch has, so there's nothing left to update."
            fi
            exit 0
          fi
          title=$(ascribe sources status --format json | jq --raw-output --slurpfile before "$RUNNER_TEMP/before.json" '
            [.sources[] as $s | ($before[0].sources | map(select(.name == $s.name))[0].commit) as $old
              | select($s.commit != $old) | "\($s.name) to \($s.commit[0:7])"]
            | if length > 0 then "Update sources: " + join(", ") else "Update sources" end')
          {
            cat "$RUNNER_TEMP/update.md"
            if ! ascribe check > "$RUNNER_TEMP/check.txt" 2>&1; then
              printf '\n### `ascribe check` fails\n\nFix these pages on this branch before merging:\n\n```text\n'
              head -n 200 "$RUNNER_TEMP/check.txt"
              printf '```\n'
            fi
            printf '\n---\n\nMerging moves the pins and updates the copies of the code, so the pages show it as it is now. While this is open, each run updates it.\n'
          } > "$RUNNER_TEMP/body.md"
          git config user.name "$BOT"
          git config user.email "$BOT@users.noreply.github.com"
          git switch --quiet --create "$BRANCH"
          git add ascribe.lock sources
          git commit --quiet --message "$title"
          # The pull request has this already: don't push it again.
          if [ "$(git log -1 --format='%T %P')" = "${pushed:-}" ]; then
            echo "The pull request is up to date." >> "$GITHUB_STEP_SUMMARY"
            exit 0
          fi
          git push --quiet --force origin "$BRANCH"
          if [ -n "$open" ]; then
            gh pr edit "$open" --title "$title" --body-file "$RUNNER_TEMP/body.md"
          else
            gh pr create --head "$BRANCH" --title "$title" --body-file "$RUNNER_TEMP/body.md"
          fi

The workflow takes two tokens from a GitHub App that you create for your organization:

  1. Create the App with no webhook, and give it two repository permissions: Contents (read and write) and Pull requests (read and write).
  2. Install it on the docs repository and the code’s repositories, and nowhere else.
  3. In the docs repository, add the App’s client ID as the Actions variable SOURCES_APP_CLIENT_ID, and a private key for it as the secret SOURCES_APP_PRIVATE_KEY.
  4. In the workflow, list the code’s repositories in repositories.

The first token can write to the docs repository only; the second can only read the code, and git uses it only while the pins move. The App’s private key can do more than either: with it, anyone who can change a workflow in the docs repository can make a token that writes to the code. When that matters, make a second App with only Contents (read), install it on the code’s repositories, and give the second token step its client ID and key. An App’s token is needed for two reasons: the default token can’t read another private repository, and a pull request it opens doesn’t run your checks until someone approves them.

Your docs repository needs a workflow that runs ascribe check on pull requests, so a pull request with a broken example fails.

The pull request’s title names each source and its new pin. Its description is what ascribe sources update --format markdown writes: the commits that came in, linked to the comparison on GitHub, the copies that changed, the tested examples that went stale or were tested again, and the pages to reread, as ascribe drift groups them. When an example no longer resolves, because its region was renamed or its file moved, the description says so, with ascribe check‘s errors, and the pull request fails your docs’ checks like any other. So does one that brings an example that went stale: example-stale is a warning, which --deny-warnings fails on, until the code’s repository runs ascribe observe again.

Because the copies are in its diff, the pull request is reviewed like any change to the docs: your checks, the HTML preview, and your site’s preview all show the pages with the new code.

  • When it opens one. Only when a copy changed. A run where the code moved but no example did changes nothing: no branch, no pull request, a green run. A run that can’t reach the code’s repository fails, with git’s message. If the examples go back to what the default branch has, it closes the open pull request.
  • Fixing a page. Push the fix to the pull request’s branch. From then on, the job leaves the branch alone, so your commits aren’t overwritten, until the pull request is merged or closed. The next run after that opens a new one.
  • Merging it brings the docs up to date with the code. Nothing merges it for you.
  • The schedule. The default is once each weekday. GitHub stops a scheduled workflow in a public repository after 60 days without activity there; you can start it again from the Actions tab, and run it by hand at any time.

This repository runs this workflow on a pair of made-up repositories, built by scripts/sources-fixture/setup.ts from examples/docs-repository: the workflow above is taken from that folder.

See also