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
20 changes: 16 additions & 4 deletions .autorc
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
{
"plugins": ["git-tag", "conventional-commits"],
"owner": "bids-standard",
"repo": "bids-utils",
"name": "bids-utils"
"baseBranch": "main",
"author": "auto <auto@nil>",
"noVersionPrefix": false,
"owner": "bids-standard",
"repo": "bids-utils",
"name": "bids-utils",
"plugins": [
"git-tag",
[
"exec",
{
"afterRelease": "python -m build && twine upload dist/*"
}
],
"released"
]
}
48 changes: 48 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Auto-release on PR merge

on:
# ATM, this is the closest trigger to a PR merging
push:
branches:
- main
workflow_dispatch:

jobs:
auto-release:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci')"
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true

- name: Download latest auto
run: |
auto_download_url="$(curl -fsSL https://api.github.com/repos/intuit/auto/releases/latest | jq -r '.assets[] | select(.name == "auto-linux.gz") | .browser_download_url')"
wget -O- "$auto_download_url" | gunzip > ~/auto
chmod a+x ~/auto

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install build & twine
run: python -m pip install --upgrade build twine

- name: Create release
run: |
echo "@${{ github.actor }} is creating a release triggered by ${{ github.event_name }}"
if [ "${{ github.event_name }}" = workflow_dispatch ]
then opts=
else opts=--only-publish-with-release-label
fi
~/auto shipit -vv $opts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}

# vim:set sts=2:
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# v0.1.0 (Mon Jun 01 2026)

Initial public alpha release of `bids-utils` — a Python library and CLI for
structural manipulation of BIDS datasets, designed to be schema-driven
(via [`bidsschematools`][bst]) and VCS-aware (plain git, git-annex, and
DataLad).

#### 🚀 What's in 0.1.0

- **CLI commands**: `rename`, `migrate`, `subject-rename`, `session-rename`,
`remove-subject`, `remove-session`, `remove-run`, `merge`, `split`,
`aggregate-metadata`, `segregate-metadata`, `audit-metadata`, `completion`.
- **Library API**: every CLI command maps to a public Python function with
`OperationResult` return values; suitable for scripted pipelines.
- **Migration engine**: applies BIDS 1.x intra-major deprecation rules
(`BasedOn`/`RawSources` → `Sources`, `ScanDate` → `_scans.tsv` `acq_time`,
`DCOffsetCorrection` → `SoftwareFilters`, `AcquisitionDuration` →
`FrameAcquisitionDuration`, deprecated coordinate-system labels, etc.) at
`safe` / `advisory` / `non-auto-fixable` tiers, with a schema-coverage
audit (`migrate --audit`) so missing rules surface as drift.
- **VCS integration**: detects DataLad → git-annex → git → plain
filesystem; uses `git mv` (or the annex equivalent) for moves; preserves
locked-annexed-symlink semantics during renames; configurable
`--annexed={error,get,skip,skip-warning}` policy for fetched content.
- **Observability**: `--dry-run=overview|detailed`, `--json` output mode,
exit codes (0 success / 1 partial / 2 refused), and per-source `Change`
entries in batch operations.
- **Test infrastructure**: per-test `git worktree` fixture pattern over the
`bids-examples` clone, plus an adversarial-suffix injection helper for
heudiconv `__dup-NN` / `+mine` / `--crap` / `_test` source filenames.
Mutating sweeps validate against the BIDS validator pre- and
post-operation across ~1700 cases.

#### ⚠ Known limitations

- The `rename --set` / `edit-filename` split (FR-039 / FR-040, atomic-batch
glob input per FR-043) is specified but **not yet implemented** —
scheduled for 0.2.0. Use `rename --set key=value FILE` (current form) for
entity edits until then.
- BIDS 2.0 migration is scaffolding-only — concrete rules are stubs.
- Performance optimization for >1000-subject datasets is unverified
(SC-003 deferred).

#### Authors: 1

- Yaroslav Halchenko ([@yarikoptic](https://github.com/yarikoptic))

[bst]: https://github.com/bids-standard/bids-specification/tree/master/tools/schemacode

---
44 changes: 44 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,47 @@ If any environment fails:
- `click` — CLI framework (core dep)
- `packaging` — version comparison for migration (core dep)
- All version specs live in `pyproject.toml` (single source of truth)

## Pull Requests and Release Labels

Releases on this project are driven by [intuit/auto][auto] — version bumps
and PyPI uploads happen automatically when a PR is merged to `main`, based
on the labels applied to that PR. **Picking the right labels is part of
the review process.**

### Bump labels (pick exactly one when you intend a release)

| Label | Effect | Use for |
| --- | --- | --- |
| `major` | bumps `X.0.0` | Breaking changes to the CLI surface or library API |
| `minor` | bumps `0.X.0` | New features, new commands, backwards-compatible additions |
| `patch` | bumps `0.0.X` | Bug fixes, performance, internal refactors, docs that ship in a release |

### Other labels

| Label | Effect | Use for |
| --- | --- | --- |
| `release` | Required alongside `major`/`minor`/`patch` to actually publish on merge. Without it the workflow exits early. | The PR that should trigger a release |
| `internal` | No version bump (counted as "skip-release") | Test-only changes, CI tweaks, refactors that don't need a release |
| `documentation` | No version bump | Docs-only PRs (mkdocs / README / CHANGELOG fixups) |
| `tests` | No version bump | Pure test additions |
| `dependencies` | No version bump | Routine dep bumps (e.g. Dependabot) |
| `performance` | No version bump on its own — combine with a bump label if a perf change should ship | Performance-only changes |
| `released` | Applied automatically by `auto` after the release ships | (do not apply manually) |

If multiple PRs are merged before a release fires, `auto` aggregates their
labels and bumps to the highest of (`major` > `minor` > `patch`).

### What the maintainer does

1. Apply the appropriate bump label and (if you want to release on merge)
the `release` label.
2. Merge to `main`.
3. The `release.yml` workflow runs `auto shipit`, which builds the
changelog entry, tags the release, creates the GitHub release, and
uploads to PyPI. No manual steps.

See [RELEASING.md](RELEASING.md) for the full release procedure, including
the manual v0.1.0 cutover and one-time repo-settings tasks.

[auto]: https://intuit.github.io/auto/
190 changes: 190 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Releasing bids-utils

This project uses [`intuit/auto`][auto] for label-driven releases on PyPI.

In normal operation **maintainers do not run any release command by hand** —
labels on merged PRs drive everything via `.github/workflows/release.yml`.
The exceptions are (a) the first release (`v0.1.0`) and (b) one-off manual
ships triggered through `workflow_dispatch`. Both are documented below.

## TL;DR — releasing a new version (0.1.1 onward)

1. On the PR you want to ship, apply **one bump label** (`major`, `minor`, or
`patch`) **and** the `release` label.
2. Merge the PR to `main`.
3. The `release.yml` workflow runs `auto shipit
--only-publish-with-release-label`, which:
- computes the new version from the bump label,
- prepends a new entry to `CHANGELOG.md`,
- creates the git tag (`vX.Y.Z`) and the GitHub release,
- runs `python -m build && twine upload dist/*` to publish to PyPI.
4. After the workflow succeeds, the merged PR is auto-commented with
"Released on `vX.Y.Z`" and labelled `released`.

If multiple PRs are merged before a release fires, `auto` aggregates their
labels and bumps to the highest of (`major` > `minor` > `patch`).

## Manual ship via workflow_dispatch

Go to **Actions → Auto-release on PR merge → Run workflow** on GitHub.
This skips the `--only-publish-with-release-label` filter and releases
whatever's on `main` since the last tag. Useful for shipping a hot fix
where the bump label was forgotten.

---

## Cutting v0.1.0 (initial release) — full runbook

The first release is intentionally manual because `auto` needs a baseline
tag (`v0.1.0`) to start counting PR labels from. After 0.1.0 the
label-driven workflow takes over.

### Step 0 — prerequisites (one-time, on the maintainer's machine)

```bash
# Ensure local build tooling is available
pip install --upgrade build twine

# Optional but recommended: install auto so you can dry-run locally
npm install -g auto # or use the binary download path from release.yml

# Ensure your PyPI account has write access to the bids-utils project,
# OR that you have a project-scoped API token for it
```

### Step 1 — merge the release-pipeline branch

Merge the `enh-auto-release` PR (containing `.autorc`,
`.github/workflows/release.yml`, `CHANGELOG.md`, `RELEASING.md`,
CONTRIBUTING.md updates) into `main`. The first release CANNOT proceed
until this is on `main`, because `release.yml` and the `auto` config are
what wire everything together.

### Step 2 — create repo labels and the PyPI token (one-time, on GitHub side)

```bash
# Create the auto release labels (major, minor, patch, release, ...)
GH_TOKEN=$(git config hub.oauthtoken) intuit-auto create-labels
```

Then on the GitHub web UI:

1. Generate a project-scoped API token at
<https://pypi.org/manage/account/token/> (scoped to `bids-utils`).
2. Add it as repo secret `PYPI_TOKEN` under
*Settings → Secrets and variables → Actions*.

If you prefer OIDC-based publishing without a long-lived token, see
"Optional: PyPI Trusted Publishing" below — equivalent end-state, just a
different setup.

### Step 3 — cut the tag and publish

```bash
# 1. Make sure you're on main and clean
git checkout main
git pull --ff-only
git status # must be clean

# 2. Run the full local test suite as a final gate
tox

# 3. Sanity-check what hatch-vcs will produce AT the tag
# (without the tag, this shows a 0.1.devNN+gSHA dev version)
python -m build
ls dist/
rm -rf dist/ # discard the dev-version artifacts; we'll rebuild at the tag

# 4. Tag v0.1.0 and push the tag
git tag -a v0.1.0 -m "v0.1.0"
git push origin v0.1.0

# 5. Build the release artifacts from the tagged commit
python -m build
ls dist/
# Expect: bids_utils-0.1.0-py3-none-any.whl AND bids_utils-0.1.0.tar.gz

# 6. (Optional but recommended) twine check before upload
twine check dist/*

# 7. Upload to PyPI
twine upload dist/*
# When prompted:
# Username: __token__
# Password: <the project-scoped pypi token from Step 2>

# 8. Create the GitHub release with the CHANGELOG entry as the body
gh release create v0.1.0 --title "v0.1.0" \
--notes "$(awk '/^# v0\.1\.0/,/^---$/' CHANGELOG.md | sed '$d')"
```

### Step 4 — sanity-check the release

```bash
# Verify the artifact is up on PyPI
pip index versions bids-utils

# Verify a fresh install works
python -m venv /tmp/bids-utils-smoke && \
/tmp/bids-utils-smoke/bin/pip install bids-utils==0.1.0 && \
/tmp/bids-utils-smoke/bin/bids-utils --help
```

### Step 5 — confirm the release.yml workflow is healthy

After v0.1.0 is tagged, the next merge to `main` will trigger
`release.yml`. If that merged PR has no `release` label, the workflow
exits early (this is the `--only-publish-with-release-label` behaviour)
— this is the expected idle state and confirms the wiring is correct.

To force a no-op smoke test of the workflow, use **Actions → Auto-release
on PR merge → Run workflow → main**: it should report "no PRs found to
release" or similar, NOT a permission error.

If the workflow fails on the first auto-driven release attempt with
a 403/permission error from `github-actions[bot]`, see "Upgrading to
the protected-branch pattern" below.

---

## Upgrading to the protected-branch pattern

If branch protection on `main` rejects pushes from `github-actions[bot]`,
the auto-release will fail with a permission error. Switch from the
default-token flow to a bot-PAT flow:

1. Create a dedicated bot account (e.g. `bidsbot`) with `write` access to
the repo.
2. Store its PAT as repo secret `PROTECTED_BRANCH_REVIEWER_TOKEN`.
3. Add `protected-branch` to the `plugins` list in `.autorc`.
4. Add `PROTECTED_BRANCH_REVIEWER_TOKEN: ${{ secrets.GH_TOKEN }}` to the
`env:` block of the release job, and `${{ secrets.GH_TOKEN }}` as
`GH_TOKEN` instead of `secrets.GITHUB_TOKEN`.

See [dandi-cli's setup][dandi-release] for a reference.

## Optional: PR label enforcement

If you want CI to refuse a PR that's missing a bump label, copy
[dandi-cli's `labels.yml`][dandi-labels] into `.github/workflows/`. This
adds an `actions/github-script` check that fails the PR unless one of
`[major, minor, patch, performance, internal, documentation, tests,
dependencies]` is applied.

## Optional: PyPI Trusted Publishing

For projects that prefer OIDC-based publishing over a long-lived PyPI
token:

1. On <https://pypi.org/manage/project/bids-utils/settings/publishing/>,
register `bids-standard/bids-utils` with workflow `release.yml` as a
Trusted Publisher.
2. Replace the `twine upload dist/*` portion of the `afterRelease` hook
(or the equivalent step in `release.yml`) with the
`pypa/gh-action-pypi-publish@release/v1` step.
3. Add `permissions: id-token: write` to the release job.
4. Drop the `PYPI_TOKEN` secret.

[auto]: https://intuit.github.io/auto/
[dandi-release]: https://github.com/dandi/dandi-cli/blob/master/.github/workflows/release.yml
[dandi-labels]: https://github.com/dandi/dandi-cli/blob/master/.github/workflows/labels.yml
Loading