Publish multiple articles from one repository

Use this configuration when one repository holds many articles. Conference proceedings, a journal issue and a lab’s report series are typical examples. One folder is nominated (e.g. papers/), and every subfolder inside it is an article that is previewed, submitted and published on its own. Authors usually contribute by opening a pull request from a fork, one article per pull request.

The SciPy Proceedings repository works this way, with one branch per year (see the 2025 and 2026 branches). Its workflows are the model for this page. Have your venue, kind, collection and CURVENOTE_TOKEN secret ready, as described in Before you start. Or let your coding agent do the setup with the agent prompt.

Repository layout

proceedings/
├── .github/
│   └── workflows/
│       ├── curvenote-preview.yml       ← label "draft": preview + checks
│       ├── curvenote-submit.yml        ← label "approved": submit the article
│       └── curvenote-publish-all.yml   ← manual: publish every article
├── papers/
│   ├── papers.yml                      ← optional shared config (exports, theme)
│   ├── _template/                      ← "_" folders are never built
│   │   ├── myst.yml
│   │   └── main.md
│   ├── ada_lovelace/
│   │   ├── myst.yml                    ← project.id: pyconf-2027-ada_lovelace
│   │   └── main.md
│   └── alan_turing/
│       ├── myst.yml                    ← project.id: pyconf-2027-alan_turing
│       └── main.md
└── README.md                           ← author instructions

Each article folder has its own myst.yml with a unique project.id. A naming convention, such as <venue>-<collection>-<folder>, makes IDs readable, and the actions can enforce the prefix with id-pattern-regex. Shared settings (exports, the site theme, PDF templates) can live in one file that each article pulls in with extends:

version: 1
extends: ../papers.yml
project:
  id: pyconf-2027-ada_lovelace
  title: A Numerical Perspective to Terraforming a Desert

How the process works

  1. An author forks the repository, copies papers/_template to papers/<their_name>, writes the article and opens a pull request.

  2. A maintainer reviews the change and adds the draft label. The preview workflow builds the article, runs the site’s checks and comments on the pull request with links. Each later push updates the comment.

  3. Reviewers read the preview. The author pushes changes until it is ready.

  4. An editor adds the approved label. The submit workflow submits the article to the site, where it appears in the site’s submissions.

  5. When the proceedings go live, an editor runs Publish all articles from the Actions tab to publish every article at once.

1. Create the labels

Create the two labels in the repository (Issues → Labels → New label) or with the GitHub CLI:

gh label create draft --description "Build a Curvenote preview"
gh label create approved --description "Submit this article to Curvenote"

Only people with triage access or higher can add labels, so authors cannot trigger the workflows themselves.

2. Add the preview workflow

Create .github/workflows/curvenote-preview.yml:

name: Curvenote preview
on:
  pull_request_target:
    branches: ['main']
    types: [labeled, opened, synchronize, reopened]
permissions:
  contents: read
  pull-requests: write
jobs:
  preview:
    uses: curvenote/actions/.github/workflows/draft.yml@v1
    with:
      venue: '<VENUE>'
      collection: '<COLLECTION>'
      kind: '<KIND>'
      path: papers/*
      exclude: papers/example
      id-pattern-regex: '^pyconf-2027-[a-zA-Z0-9_-]{3,40}$'
      enforce-single-folder: true
      label: draft
    secrets:
      CURVENOTE: ${{ secrets.CURVENOTE_TOKEN }}
      GITHUB: ${{ secrets.GITHUB_TOKEN }}

Use a quoted string for a numeric collection, such as '2027'. The options specific to this configuration are:

  • path: papers/* treats every immediate subfolder of papers/ as an article. Folders starting with . or _ are skipped. Only folders changed by the pull request are built.

  • exclude skips other folders, given as repository-relative paths separated by commas.

  • id-pattern-regex requires every built article’s project.id to match a pattern. The run fails with a clear message if one does not.

  • enforce-single-folder: true fails the run when a pull request changes more than one article, or changes files outside the article folders.

  • label: draft runs the workflow only when the pull request carries that label. The labeled trigger type makes adding the label start a run.

3. Add the submit workflow

Create .github/workflows/curvenote-submit.yml. It is identical to the preview workflow except for the reusable workflow and the label:

name: Curvenote submit
on:
  pull_request_target:
    branches: ['main']
    types: [labeled, opened, synchronize, reopened]
permissions:
  contents: read
  pull-requests: write
jobs:
  submit:
    uses: curvenote/actions/.github/workflows/submit.yml@v1
    with:
      venue: '<VENUE>'
      collection: '<COLLECTION>'
      kind: '<KIND>'
      path: papers/*
      exclude: papers/example
      id-pattern-regex: '^pyconf-2027-[a-zA-Z0-9_-]{3,40}$'
      enforce-single-folder: true
      label: approved
    secrets:
      CURVENOTE: ${{ secrets.CURVENOTE_TOKEN }}
      GITHUB: ${{ secrets.GITHUB_TOKEN }}

Both workflows update the same Curvenote Preview comment on the pull request. Adding approved replaces the draft links with the submission’s.

4. Add a publish-all workflow (optional)

Create .github/workflows/curvenote-publish-all.yml to publish every article at once, for example on the day the proceedings go live:

name: Publish all articles
on: workflow_dispatch
permissions:
  contents: write
jobs:
  publish:
    uses: curvenote/actions/.github/workflows/submit.yml@v1
    with:
      venue: '<VENUE>'
      collection: '<COLLECTION>'
      kind: '<KIND>'
      path: papers/*
      exclude: papers/example
      id-pattern-regex: '^pyconf-2027-[a-zA-Z0-9_-]{3,40}$'
      include-unchanged: true
      publish: true
    secrets:
      CURVENOTE: ${{ secrets.CURVENOTE_TOKEN }}
      GITHUB: ${{ secrets.GITHUB_TOKEN }}

include-unchanged: true builds every article folder, not only the changed ones. publish: true publishes each submission with no review step, so the token’s user needs permission to publish. Run it from the repository’s Actions tab, or with gh workflow run curvenote-publish-all.yml.

Security and forks

pull_request_target runs the workflow from your target branch with access to your secrets, against the code in the author’s pull request. That is what lets fork pull requests be previewed, and it is why both workflows are gated by labels:

  • Review the diff before adding draft or approved.

  • New commits pushed to a labeled pull request trigger the workflows again (the synchronize event). Keep reviewing, or remove the label while changes are in progress.

  • Edits to these workflow files take effect only after they are merged into the target branch. A pull request cannot change the workflow that runs on it.

GitHub’s documentation on pull_request_target explains the trade-offs in detail.

Variant: merging means accepted

A lab or team repository may have no forks, and merging may itself mean “accepted”. In that case you can drop the labels:

  • Preview: trigger on pull_request and remove label. Every pull request gets previews and checks.

  • Submit: trigger on push to main with permissions: contents: write, and remove label and enforce-single-folder. Only the article folders changed by the merge are submitted.

Fork pull requests get no preview in this variant, because GitHub withholds secrets from pull_request runs from forks.

Running per year or per issue

SciPy keeps one branch per year (2025, 2026) and points every workflow’s branches: at it. It also sets a matching collection and ID prefix (scipy-2026-…). The next year starts from a new branch with updated values, and earlier years keep working unchanged.

Other options, including strict, comment-title and the fonts and image settings, are listed in the Curvenote Actions reference. For a repository that is a single paper, use the single article configuration instead.

Abbreviations
CLI
Command Line Interface
ID
Identifier
PDF
Portable Document Format