# Release Setup: Trusted Publishing One-time configuration for publishing pyfulgur (PyPI) and fulgur (RubyGems) via OIDC Trusted Publishing, plus Fulgur's versioning policy and day-to-day release procedure. ## Versioning policy (ZeroVer) Fulgur follows [ZeroVer](https://0ver.org) while on the `0.x` line. See [`docs/plans/2026-04-26-versioning-and-release-simplification-design.md`](plans/2026-04-26-versioning-and-release-simplification-design.md) for the full rationale. Key points: - **Each normal release bumps the minor** (`0.5.14` → `0.6.0` → `0.7.0`). release-plz is configured for this (`custom_minor_increment_regex = ".*"` in `release-plz.toml`): every change to the `core` version group bumps the minor in lockstep, never the patch. - **Patch numbers are reserved for hotfixes.** release-plz never auto-bumps the patch, so a `0.6.1` hotfix is cut manually when needed rather than through the normal auto-generated Release PR. - **External form: `0.6`. Internal form: `0.6.0`.** - Internal (Cargo.toml, npm, PyPI, RubyGems, git tag, CHANGELOG section header): `0.6.0` — required by semver / registry validators. - External (README, blog, announcements, branding): "Fulgur 0.6". - **Workspace stays in lockstep** — `fulgur`, `fulgur-cli`, `fulgur-wasm`, `fulgur-ruby`, and `pyfulgur` share the same version string. Independent binding versioning is future work. - **No API stability guarantees until `1.0`.** Each minor on the `0.x` line is free to introduce breaking changes. ## Skip bindings (core-only release) To ship a core-only release (crates.io + GitHub Release + CLI binary) and suppress PyPI / RubyGems publish, add the `release:skip-bindings` label to the release-plz **Release PR** before merging it. What happens: - `release-python.yml` and `release-ruby.yml` run a `check-skip-label` job that resolves tag → commit → associated PR labels and skips the `publish` job when the `release:skip-bindings` label is present → PyPI / RubyGems are not updated. - crates.io publish, GitHub Release publish, and CLI binary uploads are unconditional — the CLI binary is treated as a core release artifact. - **npm is currently NOT skipped by this label.** The tag-triggered `release.yml` can no longer read the Release PR's labels (`github.event.pull_request` is absent on a tag push), so `publish-npm` runs unconditionally for now (tracked in fulgur-f7o2, which will give npm the same reverse-lookup as PyPI / RubyGems). If you later need to publish bindings against an already-tagged core release, trigger `release-python.yml` / `release-ruby.yml` via `workflow_dispatch`. That escape hatch is intentionally left in place but not yet documented as a first- class flow. ## PR-based changelog (`release-notes:*` labels) CHANGELOG and GitHub Release notes are generated **from merged PRs**, not from commits. `.github/release.yml` defines the category mapping. | Label | Category | |-------|----------| | `release-notes:feature` | Features | | `release-notes:fix` | Bug Fixes | | `release-notes:docs` | Documentation | | `release-notes:internal` | Excluded (CI / refactor / chore / test) | | `dependencies` | Excluded (Dependabot) | | (no label) | Other Changes | Labelling responsibility sits with the **PR author and reviewer**. CI does not enforce a `release-notes:*` label — unlabelled PRs fall through to "Other Changes". release-plz generates `CHANGELOG.md` (updated in the Release PR) and the draft GitHub Release from the commit history, per `release-plz.toml`. git-cliff has been removed. ## 初回公開時の注意 pyfulgur と fulgur gem はどちらも PyPI / RubyGems に未登録の可能性がある。 既存プロジェクトと新規プロジェクトで UI フローが異なる: - **新規 (pending publisher)**: プロジェクト名だけ予約し、初回 publish 時に OIDC claim で自動的に project が作成される。 - **既存 publisher 追加**: 既に project が存在する場合は publisher を追加登録。 ## crates.io Trusted Publisher `release-plz.yml` の `release` job は `rust-lang/crates-io-auth-action` で OIDC token を取得し、`release-plz release` 経由で crates.io に publish する。長期 PAT (`CARGO_REGISTRY_TOKEN`) を secrets に持つ必要はない。この job は `environment: crates-io`(required reviewers 付き=リリース実行承認 ②、後述)で走る。 各 crate (`fulgur`, `fulgur-cli`) で Trusted Publisher を登録する: 1. にログイン (crate owner アカウント) 2. 各 crate の Settings → "Trusted Publishing" タブを開く (例: ) 3. "Add" で以下を登録: - Repository owner: `fulgur-rs` - Repository name: `fulgur` - Workflow filename: `release-plz.yml` - Environment: `crates-io` 4. `fulgur-cli` も同様に登録 > 移行メモ (`release` → `crates-io`, ゼロダウンタイム): 既存 TP は `Environment: > release` のまま。**この env 変更が `main` に出る前に** `crates-io` の TP を**追加**する > (`release` は残す)。順序を誤り env 変更が TP 追加より先に main へ出ると、次回リリースが > crates.io 認証で失敗する。マージ後の次回リリースで env 変更+②承認+crates.io publish が > 通るのを確認してから、古い `release` の TP を削除する。crates.io は crate ごとに複数 TP を > 持てるので無停止で移行できる。 新規 crate の場合は先に 的に "Pending Trusted Publisher" で名前を予約してから初回 publish で OIDC 経由の採用が確定する。 登録完了後、旧 secret `CARGO_REGISTRY_TOKEN` は不要なので Settings → Secrets and variables → Actions から削除してよい。 ## PyPI Trusted Publisher ### Production (pypi.org) 1. にログイン 2. "Add a new pending publisher" (新規の場合) または既存 project の "Publishing" タブから publisher 追加: - PyPI Project Name: `pyfulgur` - Owner: `mitsuru` - Repository name: `fulgur` - Workflow name: `release-python.yml` - Environment name: `pypi` 3. GitHub リポジトリで Environment `pypi` を作成 (Settings → Environments → New environment)。保護ルール不要。 ### TestPyPI (test.pypi.org) 本番公開前に dry-run を試す場合のみ。 1. で同様に登録 2. Environment name: `testpypi` 3. GitHub リポジトリで Environment `testpypi` を作成 Dry-run 発火: ```bash gh workflow run release-python.yml --field dry_run=true ``` ## RubyGems Trusted Publisher 既存 gem (fulgur) の場合: 1. にログイン (gem Owner アカウント) 2. を開く 3. "Create" で以下を登録: - Repository owner: `mitsuru` - Repository name: `fulgur` - Workflow filename: `release-ruby.yml` - Environment: `rubygems` 4. GitHub リポジトリで Environment `rubygems` を作成 OIDC claim (repo + workflow + environment) で自動照合されるため、`role-to-assume` 等の値は workflow 側に不要 (`rubygems/configure-rubygems-credentials` のデフォルト動作)。 新規 gem を作成する場合は から "Pending Trusted Publisher" を登録。 注意: RubyGems には TestPyPI に相当する staging 環境がないため、`release-ruby.yml` の `workflow_dispatch` dry-run は publish をスキップするのみ (build + smoke test のみ走る)。OIDC / credential フローの実動作検証は初回の本番リリースで行う。 ## GitHub Environments 以下の Environment を作成: - `crates-io` — **required reviewers 付き**。crates.io Trusted Publisher の OIDC subject claim スコープ兼、リリース実行承認ゲート (②) - `release` — npm Trusted Publisher の OIDC subject claim スコープ (reviewers 無し) - `pypi` — PyPI Trusted Publisher の OIDC subject claim スコープ - `testpypi` (dry-run 用) - `rubygems` — RubyGems Trusted Publisher の OIDC subject claim スコープ The release has **two** approval gates (see [Approval model](#approval-model)): ① reviewing the release *contents* on the Release PR (`release-pr-approval` status check), and ② authorizing the *execution* via the `crates-io` environment. Only `crates-io` carries `Required reviewers`; the rest are OIDC subject-claim scopes only. | Environment | Required reviewers | Purpose | |-------------|--------------------|---------| | `crates-io` | **Set** (②) | crates.io OIDC subject claim **and** the release-execution approval gate | | `release` | Not set | OIDC subject claim (npm Trusted Publishing, `publish-npm`) | | `pypi` | Not set | OIDC subject claim (PyPI Trusted Publisher) | | `rubygems` | Not set | OIDC subject claim (RubyGems Trusted Publisher) | | `testpypi` | Not set | Dry-run only | `environment: release` on `release.yml`'s `publish-npm` job is **not** an approval gate (no required reviewers) but is *not* a no-op either: referencing an environment switches the job's OIDC subject to `…:environment:release`, and npm Trusted Publishing can be configured to require that environment (the optional "Environment name" field on npmjs.com). Keep it consistent with the npm trusted-publisher registration — removing it breaks npm's OIDC auth if the npm publishers are set up with the `release` environment. ### Approval model The release has **two** independent human gates: - **① Content review** — a maintainer approving the release-plz **Release PR**. This says "the release *contents* are correct" (version bumps, CHANGELOG). Enforced by the `release-pr-approval` required status check (below); a Release PR cannot merge without it. - **② Release execution** — a maintainer approving the `crates-io` **environment** deployment. This says "actually publish this now." It pauses `release-plz.yml`'s `release` job before crates.io publish + `vX.Y.Z` tag creation. Because the tag cascades to `release.yml` (GitHub Release → npm / PyPI / RubyGems), approving ② authorizes the **whole** release. Exactly one ② prompt per release. Why ① is a status check and not native "required reviews": native branch required-reviews apply to *every* PR into `main`, which would block a solo maintainer's own feature PRs (no self-approval). Instead a custom `release-pr-approval` commit status (reported by `.github/workflows/release-pr-approval-gate.yml`) is used — `success` immediately for ordinary PRs, and for `release-plz-*` PRs only once a non-author OWNER/MEMBER/COLLABORATOR has an APPROVED review on the current head commit. Making that status a **required status check** in the `main` ruleset hard-blocks an unapproved Release PR from merging. Why ② doesn't pause ordinary merges: `release-plz.yml`'s `release` job (which carries the `crates-io` env) is reachable on every push to `main`, and an environment gate would otherwise pause *every* merge. A `check-releases` job detects whether the push is an actual release (`workflow_dispatch`, a `release-plz-` merge commit, or any `chore: release …` commit in the push) and the `release` job is `if:`-gated on it, so ② prompts on real releases only. `workflow_dispatch` on `release-plz.yml` is a manual escape hatch (still gated by ②). Release flow: 1. release-plz opens a `release-plz-*` **Release PR**. ① blocks merging it until a maintainer approves the current head commit. 2. On merge, `check-releases` detects the release → the `release` job pauses on the `crates-io` environment. **② approve** → crates.io publish + `vX.Y.Z` tag (App token, so the tag fires `release.yml`). 3. The tag triggers `release.yml`: build binaries → publish the GitHub Release (App token) + npm. Publishing the Release fires `release:published`. No further approval — ② already authorized the release. 4. `release-python.yml` / `release-ruby.yml` publish to PyPI / RubyGems on `release:published`. With `skip_bindings=true` (label `release:skip-bindings` on the merged PR): step 4's publish job is skipped via `check-skip-label` (`needs.check-skip-label.outputs.skip != 'true'` gate). The OIDC claim scope (repo + workflow + environment) is independent of reviewer settings — `if: github.event_name == 'release'` separately blocks publishing from arbitrary refs. ### Security controls (partly GitHub settings, not code) The two approval gates plus a tag ruleset together authorize every publish path. Several live in repo **settings**, not code — verify each (Settings → Environments / Rules) and re-check after edits: 1. **`release-pr-approval` is a required status check** (① content review) in the `main` branch ruleset. Without it, a Release PR can merge unapproved. 2. **`crates-io` environment has required reviewers** (② release execution). Without them, the `release` job publishes crates.io + creates the tag (and thus the whole cascade) with no execution approval. 3. **`main` requires a PR** (no direct pushes) so code can't reach `main` — and thus `release-plz release` — without going through ①. Note: any actor in the ruleset's *bypass list* (e.g. the Repository Admin role) sidesteps ① and #3, so keep that list to trusted maintainers only. 4. **A `v*` tag-protection ruleset** (`RestrictReleaseTag`) restricting tag *creation* to the release App. This is the load-bearing control against the one path the gates above can't see: a `v*` tag pushed **directly** to the repo, which bypasses `release-plz.yml` entirely and lands straight on `release.yml` → GitHub Release + npm + PyPI + RubyGems. > Status as of 2026-07-02: #1, #2, #3 and #4 are live. #2 = the `crates-io` > environment with required reviewers (verified via > `gh api repos/fulgur-rs/fulgur/environments/crates-io`); `release` has no > reviewers (`…/environments/release` → `[]`). #4 = `RestrictReleaseTag` (bypass = > `fulgur-release-bot` App only, no admin blanket bypass). > > **Rollout caveat (crates.io TP):** this workflow stamps the OIDC subject > `…:environment:crates-io`, but a crates.io Trusted Publisher registered only for > `environment: release` will reject it. Register a crates.io Trusted Publisher for > the `crates-io` environment (alongside the existing `release` one) **before this > change merges**, or > the next release fails at crates.io auth; remove the old `release` publisher after > a successful release. Re-verify rulesets with `gh api repos///rulesets`. ### Tag-protection ruleset (control #3 — the direct-tag-push block) Restrict who can create `v*` tags so a rogue tag never reaches `release.yml`: 1. GitHub repo → Settings → Rules → Rulesets → **New tag ruleset**. 2. Target: **Tag** → include by pattern `v[0-9]*.[0-9]*.[0-9]*` (or `v*`). 3. Rules: enable **Restrict creations** (and **Restrict updates** / **Restrict deletions**). 4. Bypass list: add **only** the release GitHub App (`fulgur-release-bot`) — the actor that creates tags in `release-plz.yml`'s `release` job. Do **not** add the "Repository admin" role as a blanket bypass, or a human admin could still push a `v*` tag directly. 5. Enforcement status: **Active**. With this ruleset a `v*` tag can only be created by the release App, which only does so from the `release-plz release` job — after both ① (the Release PR merge) and ② (the `crates-io` environment approval). This ruleset is a GitHub Settings change; keep it in sync with the App name if the release bot is ever renamed. ## GitHub App (release publisher) `release.yml` の最終 `gh release edit --draft=false` は `release:published` イベントを 発火させ、`release-python.yml` / `release-ruby.yml` を連鎖起動する。しかし GitHub の 無限ループ防止仕様により **`GITHUB_TOKEN` で発火したイベントは別 workflow を起動しない** ([docs](https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow))。 そのため GitHub App token で publish する必要がある。 さらに `Tag release` step も同じ App token を使用する。`GITHUB_TOKEN` では `.github/workflows/*.yml` を含む commit に tag を push しようとすると `refusing to allow a GitHub App to create or update workflow ... without workflows permission` で拒否されるためで、App 側に `Workflows: Read and write` 権限があれば通る。 ### App 作成手順 1. で新規 App を作成 (個人アカウント所有でも可) - GitHub App name: `fulgur-release-bot` 等 (任意・グローバル一意) - Homepage URL: 任意 (例: リポジトリ URL) - Webhook: "Active" のチェックを外す - Repository permissions: - **Contents: Read and write** (release 作成・編集に必要) - **Workflows: Read and write** (tag push 時に workflow ファイル変更を伴う commit を通すために必要) - **Pull requests: Read and write** (`release-plz.yml` の release-pr job が この App token で Release PR を作成/更新するために必要。App 作成の PR/commit にすることで `ci.yml` (pull_request) が Release PR で自動発火する) - Where can this GitHub App be installed?: "Only on this account" 2. 作成後の App 設定画面で: - **App ID** を控える (数値) - **Private keys** → "Generate a private key" で `.pem` をダウンロード 3. 左メニュー "Install App" から対象リポジトリ (`fulgur-rs/fulgur`) に install - "Only select repositories" で fulgur のみに限定 ### リポジトリ secrets に登録 Settings → Secrets and variables → Actions → New repository secret: - `RELEASE_APP_ID`: 上記 App ID (数値) - `RELEASE_APP_PRIVATE_KEY`: ダウンロードした `.pem` ファイルの内容全体 (`-----BEGIN RSA PRIVATE KEY-----` から `-----END RSA PRIVATE KEY-----` まで) ### 動作確認 次回 release で GitHub Actions の `release.yml` → `release` job が成功したあと、 Actions タブで `release-python.yml` / `release-ruby.yml` が自動的に `release` イベントで 起動することを確認する。 ## Release procedure ### Normal release (minor bump) 1. release-plz opens (or updates) a `release-plz-*` **Release PR** automatically on pushes to `main` that warrant a release — there is no manual trigger. 2. Inspect the Release PR (CHANGELOG diff, `Cargo.toml` / aux version bumps). 3. **① Approve** the Release PR — this satisfies the `release-pr-approval` required status check (content review) — and merge it. 4. On merge, `check-releases` detects the release and `release-plz.yml`'s `release` job pauses on the `crates-io` environment. **② Approve the deployment** in the Actions UI → crates.io publish + `vX.Y.Z` tag creation (App token). (Ordinary, non-release merges never reach this prompt.) 5. The tag fires `release.yml`: build binaries → publish the GitHub Release → publish npm. Publishing the GitHub Release fires `release:published`. No further approval — ② already authorized the release. 6. `release-python.yml` / `release-ruby.yml` run on `release:published`; `check-skip-label` sees no skip label → PyPI / RubyGems publish. Two approvals per release: ① the Release PR, then ② the `crates-io` deployment (see [Approval model](#approval-model)). ### Core-only release (skip bindings) Add the `release:skip-bindings` label to the release-plz Release PR before merging it (there is no `workflow_dispatch` input for this anymore). - crates.io / GitHub Release / CLI binary still ship. - `release-python.yml` / `release-ruby.yml` still run build + smoke tests but `check-skip-label` skips their `publish` job → PyPI / RubyGems are not updated. - npm is currently **not** skipped by the label (see *Skip bindings* above; tracked in fulgur-f7o2). ### Previewing release notes release-plz builds the changelog when it opens the Release PR, so the CHANGELOG diff in that PR is the preview. To sanity-check GitHub's category grouping from the `release-notes:*` labels independently: ```bash gh api repos/fulgur-rs/fulgur/releases/generate-notes \ -f tag_name=v0.6.0 \ --jq .body ``` Add any missing categorisation labels with `gh pr edit --add-label release-notes:fix` (etc.).