Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion .docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,52 @@
# Releasing jupyddl

## The normal way: merge the release PR

Releases are **semantic and automatic**. Nobody picks a version number.

1. Every pull request is squash-merged, so its **title** becomes the commit
on `main`. The `pr-title` check requires a
[Conventional Commit](https://www.conventionalcommits.org) title:

| Title | Release it triggers |
|---|---|
| `fix: …`, `fix(lmcut): …`, `perf: …` | patch, 2.4.0 → 2.4.1 |
| `feat: …` | minor, 2.4.0 → 2.5.0 |
| `feat!: …`, or a `BREAKING CHANGE:` footer | major, 2.4.0 → 3.0.0 |
| `docs:`, `chore:`, `ci:`, `test:`, `refactor:`, `build:`, `style:` | none on its own; it rides along with the next one |

2. On every push to `main`, `release-please.yml` keeps **one release PR**
(`chore: release X.Y.Z`) up to date. It bumps `pyproject.toml` and
`jupyddl.__version__`, writes the changelog section from the merged
titles, files the hand-written `## [Unreleased]` notes under it, and
rebuilds `web/dist`, which records the version. The bump is the largest any
merged title asks for.
3. **Merging the release PR is the release.** release-please tags the merge
commit and creates the GitHub Release. It then starts `release.yml` on that
tag, which builds, verifies, publishes to PyPI and attaches the artifacts,
with the same checks as below.

Want a release after every merge? Merge the release PR right after. Want
batches? Let it collect. Either way the version follows from what went in.

**One-time repository settings** (a maintainer has to do these):

- *Settings → Actions → General → Workflow permissions*: tick **Allow GitHub
Actions to create and approve pull requests**. Without it release-please
cannot open its PR.
- *Settings → General → Pull Requests*: allow squash merging and set its
default commit message to **Pull request title**, so the checked title is
what lands on `main`.
- Do **not** make `pr-title` (or `tests`) a required check without an
exception for release-please's PR. A PR opened with the workflow's own
token starts no workflows, so those checks never report on it.
`release.yml` repeats every check that matters before publishing.

## By hand

`.github/workflows/release.yml` builds, verifies, publishes to PyPI and
creates the GitHub Release from the changelog. There are two ways to start it.
creates the GitHub Release from the changelog. You can also start it yourself,
in one of two ways.

**From the Actions tab** — no local git, nothing depending on one person's
laptop:
Expand Down
16 changes: 16 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ black . # CI reformats main automatically, but reviewers read diffs
The `learn` extra is optional at runtime and its NumPy path is not exercised by
every CI job. If you touch `jupyddl/learn/`, run the suite with it installed.

## Pull request titles

Pull requests are squash-merged, and the **title** becomes the commit that
decides the next version, so it has to be a
[Conventional Commit](https://www.conventionalcommits.org). The `pr-title`
check enforces this:

- `fix(lmcut): handle an empty goal` → patch release
- `feat: add the GOOSE feature extractor` → minor release
- `feat!: drop Python 3.10` → major release
- `docs: …`, `test: …`, `chore: …`, `ci: …`, `refactor: …` → no release on their own

Write the user-facing story under `## [Unreleased]` in `CHANGELOG.md` as
well. The title is one line; the changelog entry is where you explain.
[`.docs/RELEASING.md`](../.docs/RELEASING.md) covers the rest.

## Filing a bug

A parser or planner bug needs its input. Attach the **domain and problem
Expand Down
4 changes: 4 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<!-- Title: a Conventional Commit — `fix(scope): …` (patch), `feat: …` (minor),
`feat!: …` (major), or `docs:` / `test:` / `chore:` / `ci:` (no release). It
becomes the squashed commit that decides the next version. -->

## What and why

<!-- One or two sentences: what changes, and the problem it solves. Link the issue if there is one. -->
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: pr-title

# Pull requests are squash-merged, so the title becomes the commit on main --
# and release-please reads that commit to decide the next version. A title
# that is not a Conventional Commit is a change the release never mentions.
#
# feat: add the GOOSE feature extractor -> minor release
# fix(lmcut): handle an empty goal -> patch release
# feat!: drop Python 3.10 -> major release
# docs: explain the budget contract -> no release on its own
#
# Plain shell, no third-party action: it only reads the title.

on:
pull_request:
types: [opened, edited, reopened, synchronize]

permissions: {}

jobs:
conventional:
name: conventional title
runs-on: ubuntu-latest
steps:
- name: Check the title release-please will read
env:
TITLE: ${{ github.event.pull_request.title }}
run: |
pattern='^(feat|fix|perf|refactor|docs|test|build|ci|chore|revert|style|deps)(\([A-Za-z0-9._/-]+\))?!?: [^[:space:]].*$'
if [[ "$TITLE" =~ $pattern ]]; then
echo "ok: $TITLE"
exit 0
fi
echo "::error::The PR title is not a Conventional Commit: '$TITLE'"
echo "Use '<type>[(scope)][!]: <summary>', for example 'fix(lmcut): handle an empty goal'."
echo "Types: feat (minor), fix/perf (patch), feat!/fix! (major), docs, chore, ci, test, refactor, build, style, revert, deps."
exit 1
96 changes: 96 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: release-please

# Semantic versioning from what was merged, with no one choosing a number.
#
# Every pull request is squash-merged, so its *title* becomes the commit on
# main, and `pr-title.yml` makes that title a Conventional Commit. From those,
# release-please keeps one open "release PR" up to date:
#
# fix: ... -> patch (2.4.0 -> 2.4.1)
# feat: ... -> minor (2.4.0 -> 2.5.0)
# feat!: ... / a "BREAKING CHANGE:" footer -> major (2.4.0 -> 3.0.0)
# docs:, chore:, ci:, test:, refactor: ... -> no release on their own
#
# The release PR bumps pyproject.toml and jupyddl.__version__, writes the
# changelog section, and (job `finish-release-pr`) files the hand-written
# [Unreleased] notes under it and rebuilds web/dist, which records the
# version. Merging it is the release decision: release-please then tags the
# commit and creates the GitHub Release, and `publish` hands the tag to
# `release.yml`, which builds, verifies and publishes to PyPI exactly as a
# hand-pushed tag would.
#
# Why a dispatch rather than letting the tag trigger release.yml: a tag or
# release created with the workflow's own GITHUB_TOKEN starts no workflows,
# by GitHub's design. workflow_dispatch is the exception, so this is the one
# hand-off that works without a stored personal token.

on:
push:
branches: [main]

permissions:
contents: write # release-please: the tag, the release, its branch
pull-requests: write # release-please: open and update the release PR

concurrency:
group: release-please
cancel-in-progress: false

jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
pr: ${{ steps.release.outputs.pr }}
steps:
- id: release
uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json

finish-release-pr:
needs: release-please
if: >-
needs.release-please.outputs.pr != ''
&& needs.release-please.outputs.release_created != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ fromJSON(needs.release-please.outputs.pr).headBranchName }}
submodules: false

- uses: actions/setup-python@v7
with:
python-version: '3.12'

- name: File the hand-written notes and rebuild web/dist
run: python tools/release_pr_fixup.py

- name: Push the result to the release PR
run: |
if git diff --quiet; then
echo "Release PR already complete."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "chore: file the release notes and rebuild web/dist"
git push

publish:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
permissions:
actions: write # start release.yml
steps:
- name: Hand the new tag to the release workflow
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" -f target=pypi
echo "Started release.yml for $TAG"
22 changes: 22 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ name: release

# Publish a release to PyPI and cut the GitHub Release to match.
#
# Normally nobody starts this by hand: merging release-please's release PR
# tags the commit and `release-please.yml` dispatches this workflow on that
# tag (see the comment there for why it is a dispatch). The paths below still
# work and run exactly the same checks.
#
# Push a tag and this does the rest:
#
# git tag -a v2.3.0 -m "jupyddl 2.3.0" && git push origin v2.3.0
Expand Down Expand Up @@ -68,6 +73,23 @@ jobs:
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "pyproject declares $version"

# A dispatch from a branch creates the tag itself. If that version was
# already released, PyPI would quietly skip the files (skip-existing)
# while the existing GitHub Release had its assets replaced from a
# different commit. Refuse: a new release needs a new version.
- name: Refuse to release a version twice
if: >-
github.event_name == 'workflow_dispatch'
&& github.event.inputs.target == 'pypi'
&& !startsWith(github.ref, 'refs/tags/')
run: |
tag="v${{ steps.version.outputs.version }}"
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
echo "::error::$tag already exists. Bump the version (release-please does) before releasing again."
exit 1
fi
echo "$tag is new"

# A tag that disagrees with the package version publishes something
# nobody asked for under a name nobody expects, and PyPI will not let
# you take it back. Refuse before anything is built.
Expand Down
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "2.4.0"
}
16 changes: 13 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,24 @@ native build step, and the core has zero runtime dependencies.
rebuilds `web/dist` in the same commit; reformatting a bundled source and
committing without the rebuild would leave main in a state where `pages`
refuses to deploy.
- **`release`** — fires on a `v*` tag. Builds, refuses a tag that disagrees
- **`release-please`** — on every push to main, keeps one release PR that
bumps the version from the Conventional Commit titles merged since the last
release (`fix` patch, `feat` minor, `!`/`BREAKING CHANGE` major), files the
hand-written `[Unreleased]` notes under the new section and rebuilds
`web/dist` (`tools/release_pr_fixup.py`). Merging it tags the commit and
*dispatches* `release`: a tag made with the workflow token triggers nothing.
**PR titles must be Conventional Commits** (`pr-title` checks); squash-merge
with the PR title as the commit message. `__version__` carries an
`x-release-please-version` marker, and `.release-please-manifest.json` must
agree with it and `pyproject.toml` (`tests/test_release.py`).
- **`release`** — fires on a `v*` tag, or is dispatched by `release-please`. Builds, refuses a tag that disagrees
with `pyproject.toml` or a `__version__` that disagrees with either, runs
`twine check --strict`, installs the wheel clean and plans with it, publishes
to PyPI over OIDC (no stored token), then cuts the GitHub Release from the
changelog section for that version. `.docs/RELEASING.md` is the runbook,
including the one-time PyPI trusted-publisher setup only a maintainer can do.
**Bump the version in two places** — `pyproject.toml` and
`jupyddl/__init__.py` — and rebuild `web/dist`, which carries it too.
Do not bump versions by hand: release-please does it in both places (and the
manifest) and rebuilds `web/dist`, which carries it too.
- **`pages`** — bundles, refuses to deploy a stale `web/dist`, then deploys.
Its job is called `bundle`, not `build`: a check named `build` has to mean
the packaging workflow, and two checks with one name is a reliable way to gate
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]

### Added
- **Semantic releases, decided by what was merged.** Pull request titles are
now Conventional Commits (a `pr-title` check enforces it), and
release-please keeps one release PR whose version follows from them:
`fix` → patch, `feat` → minor, `feat!` → major. Merging that PR tags the
commit and starts the existing PyPI pipeline. The hand-written notes in this
file are filed under the new version automatically, and `web/dist` is
rebuilt with the new version. See `.docs/RELEASING.md`.
- **The intent to publish, written down.** `.docs/PUBLISHING.md` says why and
where we mean to publish jupyddl as citable research software — a Zenodo DOI
per release, a JOSS paper, ICAPS workshops for the research results — with a
Expand Down
2 changes: 1 addition & 1 deletion jupyddl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
TraceRecorder,
)

__version__ = "2.4.0"
__version__ = "2.4.0" # x-release-please-version

__all__ = [
"solve",
Expand Down
31 changes: 31 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "python",
"include-component-in-tag": false,
"include-v-in-tag": true,
"pull-request-title-pattern": "chore: release ${version}",
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "revert", "section": "Reverts" },
{ "type": "docs", "section": "Documentation" },
{ "type": "deps", "section": "Dependencies" },
{ "type": "refactor", "section": "Refactoring", "hidden": true },
{ "type": "test", "section": "Tests", "hidden": true },
{ "type": "build", "section": "Build", "hidden": true },
{ "type": "ci", "section": "CI", "hidden": true },
{ "type": "chore", "section": "Chores", "hidden": true },
{ "type": "style", "section": "Style", "hidden": true }
],
"packages": {
".": {
"package-name": "jupyddl",
"changelog-path": "CHANGELOG.md",
"extra-files": [
{ "type": "toml", "path": "pyproject.toml", "jsonpath": "$.project.version" },
{ "type": "generic", "path": "jupyddl/__init__.py" }
]
}
}
}
1 change: 1 addition & 0 deletions renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"config:recommended",
"helpers:pinGitHubActionDigests"
],
"semanticCommits": "enabled",
"packageRules": [
{
"description": [
Expand Down
Loading
Loading