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
55 changes: 44 additions & 11 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
name: Publish to Maven Central

# Subsequent releases are a single tag push: pushing v<version> 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<version> -> 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:
Expand All @@ -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
Expand All @@ -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."
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -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<version> ──▶ 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 <https://central.sonatype.com> and open **Deployments**:
<https://central.sonatype.com/publishing/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:<version>`
- `io.github.mibrahimdev:sharingan-noop:<version>`

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 (`<version>`, 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`.
67 changes: 67 additions & 0 deletions docs/adr/0001-stage-then-manual-release.md
Original file line number Diff line number Diff line change
@@ -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.