Skip to content

Publish core/core-noop to Maven Central as com.callstack.appduct - #60

Merged
V3RON merged 3 commits into
mainfrom
claude/maven-central-publishing
Sep 16, 2026
Merged

V3RON merged 3 commits into
mainfrom
claude/maven-central-publishing

Conversation

@V3RON

@V3RON V3RON commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Publishes core / core-noop to Maven Central, and moves them to the namespace you actually own.

Coordinates change

debugImplementation("com.callstack.appduct:core:0.9.0")
releaseImplementation("com.callstack.appduct:core-noop:0.9.0")

The old com.callstackincubator.appduct could never have been published. Central verifies a namespace against the reversed domain, and callstackincubator.com is not a registered domain at all (whois returns No match). callstack.com is, and is the verified namespace on the account.

Only the Maven coordinates move. The Kotlin package and the AGP namespace stay com.callstackincubator.appduct*. Those are three unrelated concepts, and the Kotlin package is load-bearing: appduct doctor's Android detection keys on the com.callstackincubator.appduct dex package, so renaming it would silently change what the release gate inspects. Historical task docs keep the old coordinates — they are dated records.

No third-party plugin

Sonatype ships no official Gradle plugin for the Central Portal, and the community alternatives mostly automate what the new publishing.gradle does in ~40 lines. Keeping it first-party keeps the Android build classpath to AGP and Kotlin, matching the pinning rationale already in the root build.gradle.

The trick is publishing to a file repository rather than a remote one. The Portal takes a single zipped bundle, not Maven-protocol uploads, and a file repository produces exactly the layout that bundle needs — full group path plus the .md5/.sha1 checksums Central requires on every file, written by Gradle rather than by hand. Both modules stage into one directory so a single zip covers the release; maven-metadata.xml is excluded because Central generates its own.

Two safety properties worth reviewing

Signing is conditional. The signing plugin is wired only when APPDUCT_SIGNING_KEY is present, so test.yaml's publishToMavenLocal keeps working on an unkeyed machine. That leniency could otherwise let the job upload an unsigned bundle and fail deep inside Central's validator — so publish-maven asserts every staged artifact has a .asc beside it before zipping.

The final Publish is manual. Upload uses publishingType=USER_MANAGED: Central validates, then waits for a human to press Publish in the Portal UI. A Central version can never be replaced or deleted, so the last irreversible step stays deliberate until this path has proven itself. Switching to AUTOMATIC later is a one-word change. The job polls the status endpoint and fails on FAILED, so a rejected bundle still breaks the release loudly.

Verification

Run end to end locally with a throwaway GPG key, since destroyed:

  • Both modules stage; all 10 artifacts signed and checksummed, correct com/callstack/appduct/... layout
  • gpg --verify accepts a generated signature
  • POM carries every field Central requires — name, description, url, licenses, developers, scm
  • Javadoc jar now produced (withJavadocJar()); Central rejects a deployment without one
  • Zip has the right layout, 57 entries, 0 maven-metadata.xml
  • The unsigned-artifact assertion correctly fails an unkeyed build (caught all 10)
  • publishToMavenLocal still succeeds with no key present — the constraint that keeps test.yaml green
  • actionlint clean; check:links passes

Not verified: the actual Portal upload, which needs the real token and cannot be rehearsed without consuming version 0.9.0 — Central is immutable.

Setup needed before a release

A maven-central environment with four secrets:

  • CENTRAL_TOKEN_USERNAME / CENTRAL_TOKEN_PASSWORD — Portal user token
  • APPDUCT_SIGNING_KEY / APPDUCT_SIGNING_PASSWORD — armored private key, used in memory and never written to the runner's disk

The GPG walkthrough is in the PR discussion.

…ack.appduct

Changes the Maven coordinates from `com.callstackincubator.appduct` to
`com.callstack.appduct` and wires the publish.

The old group could never have been published: Central verifies a
namespace against the reversed domain, and callstackincubator.com is not a
registered domain at all. callstack.com is, and is the verified namespace
on the account.

Only the Maven coordinates move. The Kotlin package and the AGP namespace
stay `com.callstackincubator.appduct*` -- three unrelated concepts, and the
Kotlin package is load-bearing: `appduct doctor`'s Android detection keys
on that dex package name, so renaming it would silently change what the
release gate inspects. Historical task docs keep the old coordinates; they
are dated records.

The publish itself uses no third-party plugin. Sonatype ships no official
Gradle plugin for the Central Portal, and the community ones largely
automate what publishing.gradle now does directly: stage a signed,
checksummed repository layout on disk, which `publish-maven` zips and POSTs
to the Portal API. Publishing to a *file* repository is what produces that
layout, checksums included, without hand-rolling any of it.

Signing is wired only when APPDUCT_SIGNING_KEY is set, so test.yaml's
`publishToMavenLocal` keeps working unkeyed. Because Central rejects any
unsigned file, the job asserts every staged artifact has a .asc before
zipping rather than discovering it inside Central's validator.

Uploads as USER_MANAGED: Central validates, then waits for a human to press
Publish. A Central version can never be replaced or deleted, so the last
irreversible step stays deliberate for now.

Verified end to end with a throwaway GPG key (since destroyed): both
modules stage, all 10 artifacts signed and checksummed, `gpg --verify`
accepts a signature, the POM carries every field Central requires, the zip
has the right layout with maven-metadata.xml excluded, and the
unsigned-artifact assertion correctly fails an unkeyed build. Also
confirmed `publishToMavenLocal` still succeeds with no key present.
#60 moved only the Maven coordinates, leaving the Kotlin package, the AGP
namespace, the manifest meta-data keys, and the native playground's
application id on com.callstackincubator.appduct. Two namespaces in one
project is a standing invitation to reach for the wrong one, so this moves
the rest.

The reason the split was tempting to leave alone is also the reason it was
dangerous: the knobs are technically independent, but one coupling is
invisible. `appduct doctor`'s Android detection keys on the dex package
name, so changing the Kotlin package silently changes what the release gate
inspects. That coupling is now explicit rather than load-bearing folklore.

`artifact-inspect.ts` therefore matches every Android marker against both
`com.callstack.*` and the legacy `com.callstackincubator.*` spelling.
`doctor` inspects artifacts it did not build -- an app compiled against
Appduct <= 0.9.0 is exactly what a release gate meets -- and reporting
`absent` for an app that genuinely bundles Appduct would rubber-stamp
shipping it to production, the one failure this tool must never have
(docs/CI.md: a broken check must fail loudly, never rubber-stamp). A new
regression test covers a pre-rename artifact and fails if those legacy
entries are dropped.

The GitHub organisation is still `callstackincubator`, so every repository
URL is untouched -- note `github.com/callstackincubator/appduct` contains
the string `com/callstackincubator/appduct`, which a naive rename would
have corrupted. Task documents keep the old spelling as dated records.

Verified: core unit tests and core-noop assemble pass; the native
playground builds Debug and Release; `doctor --assert-present` holds on the
debug APK with all three signals and `--assert-absent` holds on release;
lint, typecheck, and the 272 JS tests covering detection, the config plugin
and the CLI all pass. The new regression test was confirmed to fail when
the legacy markers are removed.
@V3RON

V3RON commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Added 582f49f: every namespace now reads com.callstack.appduct — Kotlin package, AGP namespace, manifest meta-data keys, and the native playground's application id, not just the Maven coordinates.

You were right that leaving two was the bigger risk. The reason it was tempting to leave alone is also why it was dangerous: those knobs are technically independent, but one coupling is invisible — appduct doctor's Android detection keys on the dex package name, so changing the Kotlin package silently changes what the release gate inspects. That is now explicit rather than folklore in a comment.

Backward compatibility (please review this bit)

artifact-inspect.ts matches every Android marker against both com.callstack.* and the legacy com.callstackincubator.* spelling. doctor inspects artifacts it did not build — an app compiled against Appduct ≤ 0.9.0 is exactly what a release gate runs into — and reporting absent for an app that genuinely bundles Appduct would rubber-stamp shipping it to production. A new regression test covers a pre-rename artifact, and I confirmed it fails when the legacy entries are removed.

One trap avoided

The GitHub org is still callstackincubator, and github.com/callstackincubator/appduct contains the substring com/callstackincubator/appduct. A naive rename would have rewritten all 67 repo URLs to a 404. Every URL is untouched; only namespace occurrences moved. Task docs keep the old spelling as dated records.

Verification

  • Android: :core:testDebugUnitTest and :core-noop:assembleRelease pass
  • Native playground builds Debug and Release
  • doctor --assert-present holds on the debug APK with all three signals; --assert-absent holds on release — the gate still behaves exactly as designed
  • lint, typecheck, and 272 JS tests covering detection, the config plugin and the CLI

Unrelated local flake worth noting: 14 daemon tests fail on my machine with EADDRINUSE on port 8443, held by a process from another checkout (issue #39's hard-coded port). Nothing to do with this change, and CI won't hit it.

publish-cocoapods skipped a version already on trunk; publish-maven had no
equivalent, so re-running it could upload a second deployment of the same
version.

Two layers, because one check is not enough:

1. Already published: repo1.maven.org, OR the Portal's `published`
   endpoint on an explicit `"published": true`. Skip if either says so.

2. Uploaded but not yet published -- the likeliest re-run, e.g. the status
   poll timing out after a successful upload. That deployment is VALIDATED
   but unpublished, so layer 1 says "not published" and would upload again.
   The Portal API has no endpoint to list deployments, so the id is the
   only handle on it: the job now saves it as a run artifact immediately
   after uploading, *before* polling, and a re-run attempt restores it and
   resumes watching that deployment instead of uploading a duplicate.

Both layers lean the same way. Only explicit evidence counts as
"published", and only live states (PENDING, VALIDATING, VALIDATED,
PUBLISHING, PUBLISHED) are resumed; FAILED, dropped, unparseable, or
unrecognised answers all fall through to a fresh upload. A wrong skip would
end a release green with nothing on Central; a wrong upload at worst leaves
a duplicate for a human to drop.

The upload step also now refuses any response that is not a bare deployment
id, so a re-run can never try to resume junk.

Verified by extracting the real `run:` blocks from the workflow and
executing them under `bash -e` against a fake curl, 20 scenarios: every
published/not-published/garbage/network-failure branch of the check, every
deployment state plus dropped/errored/unknown for the reuse decision, and
valid, newline-terminated, error, failed, and empty upload responses.
actionlint is clean.

Not verifiable locally: that a re-run attempt can download an artifact
uploaded by an earlier attempt of the same run. This workflow already
relies on that -- publish-* download `npm-packages` from `package` -- but
not for an artifact written by the failing job itself.
@V3RON
V3RON merged commit e8563ae into main Sep 16, 2026
8 checks passed
@V3RON
V3RON deleted the claude/maven-central-publishing branch September 16, 2026 10:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant