diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ac5d362..be9df96 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,16 +1,38 @@ name: Publish to Maven Central -# Subsequent releases are a single tag push: pushing v uploads and -# releases both :sharingan and :sharingan-noop to Maven Central in one step, -# then cuts the GitHub Release. The tag must equal the project version -# (gradle/libs.versions.toml -> sharingan) or the job fails before publishing. +# Releases are two steps by deliberate design (see decision note below): +# +# 1. Push tag v -> this workflow STAGES both :sharingan and +# :sharingan-noop to the Maven Central Portal and STOPS. Nothing is public +# yet; the deployment sits in the portal's "Deployments" list awaiting a +# human. +# 2. A maintainer verifies the staged deployment in the Central Portal UI and +# clicks "Publish" to release it, then cuts the matching GitHub Release. +# See docs/RELEASING.md for the exact portal checklist. +# +# DECISION (ADR docs/adr/0001-stage-then-manual-release.md): we run +# `publishToMavenCentral` (stage-only), NOT `publishAndReleaseToMavenCentral` +# (upload-and-auto-release). Central releases are PERMANENT and can never be +# unpublished, so the project chose a human gate at the portal over an +# automated end-to-end release. The cost is one manual click per release; the +# benefit is that a bad artifact can be dropped from staging instead of living +# on Central forever. +# +# The tag must equal the project version (gradle/libs.versions.toml -> +# sharingan) or the job fails before publishing. on: push: tags: - "v*" + # Manual trigger so a maintainer can exercise the staged flow (and confirm it + # reaches the portal without releasing) without having to cut a real tag. + # NOTE: a workflow_dispatch run does NOT have a tag, so the version-tag + # assertion is skipped for dispatch runs (see the step's `if:`). Use this only + # for dry-runs / re-staging an already-tagged version. + workflow_dispatch: permissions: - contents: write # create the GitHub Release for the tag + contents: read jobs: publish: @@ -26,7 +48,9 @@ jobs: # Fail loudly if the pushed tag does not match the catalog version, so we # never publish mislabeled artifacts. Reads $GITHUB_REF_NAME (the tag). + # Skipped on manual workflow_dispatch runs, which have no tag. - name: Assert tag matches project version + if: github.event_name == 'push' run: ./scripts/check-version-tag.sh # The signing key secret is base64-encoded (a single clean line in the @@ -42,15 +66,24 @@ jobs: echo "__SHARINGAN_GPG_EOF__" } >> "$GITHUB_ENV" - - name: Publish and release to Maven Central + # STAGE ONLY. `publishToMavenCentral` uploads both modules to a new Central + # Portal deployment and stops. It does NOT release. A human must verify and + # click "Publish" in the portal afterwards (docs/RELEASING.md). This is the + # single line that enforces the human-gated release policy -- do not switch + # it back to `publishAndReleaseToMavenCentral`. + - name: Stage to Maven Central (no auto-release) env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} # ORG_GRADLE_PROJECT_signingInMemoryKey is set in the decode step above. - run: ./gradlew publishAndReleaseToMavenCentral + run: ./gradlew publishToMavenCentral - - name: Create GitHub Release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --generate-notes + - name: Staged - next steps + run: | + echo "::notice title=Staged to Central Portal::Artifacts uploaded to a NEW deployment on the Central Portal and are NOT yet public." + echo "Next: a maintainer must verify and release manually. See docs/RELEASING.md." + echo " 1. Open https://central.sonatype.com/publishing/deployments" + echo " 2. Confirm io.github.mibrahimdev:sharingan and io.github.mibrahimdev:sharingan-noop are present, validated, and signed." + echo " 3. Click Publish on the deployment to release it to Central." + echo " 4. Cut the matching GitHub Release for this tag." diff --git a/README.md b/README.md index a997cc8..c0af378 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,9 @@ git clone https://github.com/mibrahimdev/Sharingan && cd Sharingan ./gradlew publishToMavenLocal ``` +Maintainers: cutting a Maven Central release is a two-step, +stage-then-manually-release flow — see [`docs/RELEASING.md`](docs/RELEASING.md). + ### Android app (two lines) ```kotlin diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..4ffc0f9 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,104 @@ +# Releasing Sharingan + +Sharingan ships two artifacts to Maven Central under `io.github.mibrahimdev`: + +| Coordinate | Module | +| ------------------------------------- | ----------------- | +| `io.github.mibrahimdev:sharingan` | `:sharingan` | +| `io.github.mibrahimdev:sharingan-noop`| `:sharingan-noop` | + +Releases are **two steps by deliberate design**: CI *stages* a deployment to +the Central Portal, then a human *verifies and releases* it. We do **not** +auto-release, because **Maven Central releases are permanent and can never be +unpublished** (see [ADR 0001](adr/0001-stage-then-manual-release.md)). + +``` +push tag v ──▶ CI stages to Central Portal ──▶ STOP (awaiting human) + │ + maintainer verifies + clicks ◀─────┘ + "Publish" in the portal, then + cuts the GitHub Release +``` + +## Step 1 — cut the release tag (triggers CI staging) + +1. Make sure `gradle/libs.versions.toml` → `sharingan` holds the version you + intend to release (e.g. `0.1.0`), and it is merged to `main`. +2. Tag and push: + + ```bash + git checkout main && git pull + git tag v0.1.0 # must equal the catalog version, with a leading "v" + git push origin v0.1.0 + ``` + + The tag **must** equal the catalog version or the + `Assert tag matches project version` step fails before anything is published + (`scripts/check-version-tag.sh`). + +3. The `Publish to Maven Central` workflow runs `./gradlew publishToMavenCentral`. + This **uploads both modules to a new deployment on the Central Portal and + stops** — nothing is public yet. The job's `Staged - next steps` summary + links straight back to this checklist. + +## Step 2 — verify the staged deployment in the Central Portal + +1. Sign in at and open **Deployments**: + +2. Find the new deployment from the CI run. Confirm **all** of: + - **Status** is `VALIDATED` (not `FAILED`). If it failed, expand it to read + why — do **not** publish a failed deployment; fix and re-stage instead. + - **Both** components are present under group `io.github.mibrahimdev`: + - `io.github.mibrahimdev:sharingan:` + - `io.github.mibrahimdev:sharingan-noop:` + + If only one module is there, something is wrong — drop the deployment and + re-stage. Never release a half-uploaded version. + - The **version** matches the tag (``, no stray `-SNAPSHOT`). + - Each component carries its **`.pom`**, the main artifact, **`-sources`** + and **`-javadoc`** jars, and a matching **`.asc` signature** for every + file (the portal flags missing/invalid signatures during validation). + - POM metadata looks right: `name`, `description`, project `url`, Apache-2.0 + license, developer `mibrahimdev`, and SCM pointing at the repo. + +## Step 3 — release (the irreversible click) + +1. Once you are satisfied, click **Publish** on the deployment in the Central + Portal. This releases it to Maven Central. **This cannot be undone.** +2. If you are *not* satisfied, click **Drop** instead. The staged deployment is + discarded and nothing reaches Central; fix the problem and re-stage from a + new CI run (re-run the workflow or re-push the tag). +3. Propagation to `repo1.maven.org` / `search.maven.org` typically takes + minutes to a couple of hours. + +## Step 4 — cut the GitHub Release + +CI no longer auto-creates the GitHub Release (it would otherwise announce a +"release" while the artifact is still only staged). Once the Central +deployment is **Published**, cut the matching GitHub Release yourself: + +```bash +gh release create v0.1.0 --title v0.1.0 --generate-notes +``` + +## Dry-run / re-staging without cutting a tag + +The workflow also has a **`workflow_dispatch`** trigger so you can exercise the +staged flow — and confirm it reaches the portal *without releasing* — without +cutting a real tag: + +- GitHub → **Actions** → **Publish to Maven Central** → **Run workflow**. + +A `workflow_dispatch` run has no tag, so the version-tag assertion is skipped; +it stages the *current* catalog version. Use this to dry-run the pipeline or to +re-stage a version whose previous deployment you dropped. Because the task is +`publishToMavenCentral` (stage-only), even a dispatch run will **never** +auto-release — it always stops at the portal awaiting your **Publish** click. + +## Why two steps (and not auto-release) + +Recorded in [ADR 0001 — Stage to Maven Central, then release manually](adr/0001-stage-then-manual-release.md). +Short version: Central releases are irreversible, so a human verifies the +fully-assembled, signed deployment in the portal before the one click that +makes it permanent. Do **not** switch the workflow back to +`publishAndReleaseToMavenCentral`. diff --git a/docs/adr/0001-stage-then-manual-release.md b/docs/adr/0001-stage-then-manual-release.md new file mode 100644 index 0000000..180a195 --- /dev/null +++ b/docs/adr/0001-stage-then-manual-release.md @@ -0,0 +1,67 @@ +# ADR 0001: Stage to Maven Central, then release manually + +- Status: Accepted +- Date: 2026-06-14 +- Decision owner: maintainer (@mibrahimdev) +- Issue: [#14 — De-risk Maven Central publish](https://github.com/mibrahimdev/Sharingan/issues/14) + +## Context + +Sharingan publishes two artifacts to Maven Central under +`io.github.mibrahimdev` (`:sharingan` and `:sharingan-noop`) via the +vanniktech `gradle-maven-publish-plugin` (0.36.0), driven by a tag-triggered +GitHub Actions workflow (`.github/workflows/publish.yml`). + +Until this decision, that workflow ran the Gradle task +`publishAndReleaseToMavenCentral`, which uploads **and** auto-releases the +deployment to Central in a single step on every `v*` tag push. + +**Maven Central releases are permanent.** Once a coordinate+version is released +it can never be unpublished or overwritten. An auto-release flow means any +defect that slips past CI — a wrong coordinate, a missing/broken POM, an +unsigned or mis-signed artifact, only one of the two modules uploaded — becomes +a forever-public mistake, only fixable by burning the version number and +shipping a new one. + +The plugin exposes two relevant Gradle tasks: + +- `publishToMavenCentral` — uploads to a **new deployment** on the Central + Portal and **stops**. The deployment sits in the portal awaiting a human, who + verifies it and clicks **Publish** to release. +- `publishAndReleaseToMavenCentral` — uploads **and** releases automatically, + end to end, with no human gate. + +## Decision + +The publish workflow runs **`publishToMavenCentral`** (stage-only). Releasing +the staged deployment is a deliberate manual step performed by a maintainer in +the Central Portal UI, per the checklist in [`docs/RELEASING.md`](../RELEASING.md). + +We deliberately chose **staging + manual release** over the alternative of +**automated release behind an in-CI verify gate**. A CI gate can only assert +what we thought to script; the Central Portal already shows the +fully-assembled, validated, signed deployment exactly as consumers would +receive it, and a human eyeballing that — coordinates, both modules, POM, +signatures — is the strongest cheap check against an irreversible mistake. The +marginal cost is a single click per release. + +## Consequences + +- A tag push no longer publishes to Central. It stages, then waits. Releases + require a human to click **Publish** in the portal — the GitHub Release is + cut by the maintainer at the same time, not auto-created by CI. +- The workflow also gained a `workflow_dispatch` trigger so the staged flow can + be exercised manually (dry-run) without cutting a tag. The tag trigger is + unchanged. +- Anyone tempted to "speed things up" by switching back to + `publishAndReleaseToMavenCentral` is re-introducing an irreversible + auto-release; the workflow and this ADR both flag that explicitly. + +## Alternatives considered + +- **Automated release with an in-CI verify gate** (smoke-resolve the staged + artifact, then auto-release if green). Rejected: still auto-releases something + permanent, and the gate only checks what we remembered to script. The portal's + human review is cheaper and catches the long tail. +- **Status quo (`publishAndReleaseToMavenCentral`).** Rejected: one irreversible + shot per tag, no human in the loop, the original motivation for issue #14.