From 7f91413f7958fc329ff4184e13f900821367a16b Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 26 Aug 2026 15:50:10 +0100 Subject: [PATCH 1/3] chore: separate consumer docs from maintainer docs, and Appoly from the script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repository is public, so three things that were fine while it was effectively internal are no longer. The publish script hard-failed for anyone who is not Appoly: it required the 1Password CLI and a vault item nobody else can reach, with no alternative. It now resolves credentials from the environment first and only falls back to 1Password, so a fork, a different secret manager, or CI needs no diff against the script. Supplying only some of the five variables is now rejected rather than topped up from the vault — mixing sources silently is how a release gets signed with one identity and uploaded with another. The vault coordinates are out of version control. They are not secrets, but they are reconnaissance: which 1Password account, which vault, which item holds a signing key. They move to a git-ignored scripts/publish.conf, with publish.conf.example documenting the shape. Maintainers need their own copy; CONTRIBUTING.md says so. The README told consumers how to run the release script, which needs vault access and so is unusable by almost everyone reading it — and it sat above the BOM section. That moves to CONTRIBUTING.md along with the release process, which CLAUDE.md now points at instead of restating. Also deletes the migration plan. The migration is done; a plan document only goes stale from here. Its durable parts are in the 1.9.0 release notes, which the README now links for the three consumers still to move, and the rest is in git history. Verified: the 1Password path passes a full dry run; a fork with no config gets an error naming both options; environment-supplied credentials skip 1Password entirely. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + CLAUDE.md | 16 +- CONTRIBUTING.md | 144 ++++++++++++++++++ README.md | 34 +---- docs/maven-central-migration.md | 260 -------------------------------- scripts/publish.conf.example | 32 ++++ scripts/publish.sh | 168 +++++++++++++++------ 7 files changed, 314 insertions(+), 343 deletions(-) create mode 100644 CONTRIBUTING.md delete mode 100644 docs/maven-central-migration.md create mode 100644 scripts/publish.conf.example diff --git a/.gitignore b/.gitignore index 9ee14ec..b2d71a0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ local.properties graphify-out/* !graphify-out/memory/ !graphify-out/GRAPH_REPORT.md + +# Fork-specific publishing settings (see CONTRIBUTING.md) +scripts/publish.conf diff --git a/CLAUDE.md b/CLAUDE.md index 7349e6d..c677fde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,12 +121,16 @@ Published to **Maven Central** under `uk.co.appoly.droid`, with lowercase artifa - `com.vanniktech.maven.publish` handles signing, sources and javadoc. Shared POM metadata and coordinates live in the root `build.gradle.kts`; a module declares only its name and description. - Central rejects an incomplete POM — a missing `developers` block is a hard rejection. -- Credentials come from 1Password (*Appoly Shared → Appoly Maven Central Signing*), never from - `~/.gradle/gradle.properties`. Gradle reads them only under the `ORG_GRADLE_PROJECT_` prefix with - exact camelCase. +- Credentials come from the environment or 1Password, never from `~/.gradle/gradle.properties`. + Gradle reads them only under the `ORG_GRADLE_PROJECT_` prefix with exact camelCase. The vault + item is set in the git-ignored `scripts/publish.conf` — this repo is public, so it is not + committed. See `scripts/publish.conf.example`. - `./scripts/publish.sh --local` publishes signed artifacts to `~/.m2`; without `--local` it releases to Central. **Releases are run manually and locally** — there is no release CI job and no Maven Central secrets in the repo, so a version tag publishes nothing on its own. +- **`CONTRIBUTING.md` is the source of truth for the release process** — credentials, fork config, + and why every module shares one version. Update it there rather than restating it here. The + JitPack → Central migration is complete; its history lives in the 1.9.0 release notes. - PR CI runs `publishToMavenLocal` with no signing key, so `signAllPublications()` is applied only when a key is present. A task-graph guard refuses any Central upload without one, so the relaxation cannot reach a real release. @@ -137,12 +141,6 @@ Published to **Maven Central** under `uk.co.appoly.droid`, with lowercase artifa `publishToMavenLocal` first. - Each module also ships consumer ProGuard rules (`consumer-rules.pro`). -## Publishing migration - -`docs/maven-central-migration.md` is the working plan and status for the JitPack → Central move: -what has landed, what is deliberate deviation, and what remains (publish 1.9.0, then migrate the -four in-house consumers). Update it as phases complete rather than letting it drift. - ## Knowledge Graph (graphify) This repo can be analysed with `/graphify` — it builds a navigable knowledge graph of the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..54ffa17 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,144 @@ +# Contributing + +Maintainer and contributor notes. The [README](README.md) covers *using* the toolbox; this file +covers building, testing and releasing it. + +## Testing an unreleased change + +Maven Central publishes only what is released, so there is no equivalent of JitPack's +build-any-branch behaviour. Two options replace it. + +**Install locally.** From a checkout of the branch you want to test: + +```bash +./scripts/publish.sh --local +``` + +That publishes every module to `~/.m2`, signed. Add `mavenLocal()` to the consuming project's +repositories, ahead of `mavenCentral()`. + +> Take `mavenLocal()` out again before committing, and before drawing any conclusion about a +> released version. A locally published build carries the same version string as the real one, so +> leaving it in means resolving your own artifacts while believing you are testing the release. + +**Publish a snapshot.** Snapshot versions go to Central's snapshot repository rather than the main +one, and need it adding explicitly: + +```kotlin +maven { url = uri("https://central.sonatype.com/repository/maven-snapshots/") } +``` + +Snapshots are changing modules, so pass `--refresh-dependencies` or Gradle will cache one for 24 +hours and you may silently keep testing a stale copy. + +## Releasing + +Releases are run **manually and locally** by a developer. There is no release CI job and no Maven +Central credentials in the repository, so pushing a version tag publishes nothing on its own. + +```bash +./scripts/publish.sh --dry-run # every gate; publishes and tags nothing +./scripts/publish.sh # gates, then upload, then tag +``` + +The script cleans, runs the tests and coverage gate, checks the consumer R8 keep rules, publishes +to `~/.m2` and verifies that the published metadata resolves for an Android consumer — then asks +for confirmation before uploading. It tags only *after* a successful upload, so a failed publish +never leaves a tag claiming otherwise. + +Bump `TOOLBOX_VERSION` in `buildSrc/src/main/kotlin/BuildConfig.kt` first. Every module shares that +one version; see [Why one version for all modules](#why-one-version-for-all-modules). + +> **Releases are immutable.** A version can never be re-uploaded or corrected — the only remedy is +> publishing a new one. Iterate with `--local` *before* releasing, never after. + +### Credentials + +The script resolves credentials in two ways, in this order: + +1. **Already in the environment.** If all of the variables below are exported, they are used as-is + and 1Password is never invoked. This is the path for forks, other secret managers, and CI. +2. **1Password.** Otherwise they are read from a vault item via the `op` CLI. Which item is + deployment-specific and deliberately not in version control: copy + `scripts/publish.conf.example` to `scripts/publish.conf` and set `PUBLISH_VAULT_ITEM`. + Appoly maintainers can get the value from the team. + +Gradle reads these only under the `ORG_GRADLE_PROJECT_` prefix, with exact camelCase: + +| Variable | 1Password field | +|---|---| +| `ORG_GRADLE_PROJECT_signingInMemoryKey` | `private-key` | +| `ORG_GRADLE_PROJECT_signingInMemoryKeyId` | `key-id` | +| `ORG_GRADLE_PROJECT_signingInMemoryKeyPassword` | `passphrase` | +| `ORG_GRADLE_PROJECT_mavenCentralUsername` | `portal-username` | +| `ORG_GRADLE_PROJECT_mavenCentralPassword` | `portal-token` | + +Setting *some* of them is rejected rather than topped up from the vault — mixing sources silently is +how a release gets signed with one identity and uploaded with another. + +A 1Password field label containing a space or parenthesis cannot be read through an `op://` +reference at all, so these labels use hyphens. + +## Forking + +Nothing here is Appoly-specific except the defaults. Create `scripts/publish.conf` (git-ignored) to +publish under your own coordinates: + +```bash +cp scripts/publish.conf.example scripts/publish.conf +``` + +```bash +PUBLISH_GROUP="com.example.toolbox" +PUBLISH_RELEASE_BRANCH="main" +``` + +Then export the five `ORG_GRADLE_PROJECT_*` variables from wherever you keep secrets and run +`./scripts/publish.sh`. The 1Password path is Appoly's convenience, not a requirement — you do not +need the `op` CLI installed. + +You will also want to change the POM metadata (`url`, `licenses`, `developers`, `scm`) in the root +`build.gradle.kts`, and the `coordinates(...)` group alongside it. Maven Central rejects an +incomplete POM, and a missing `developers` block is a hard rejection. + +## Build and verification + +```bash +./gradlew test koverVerify # unit tests + coverage floor +./gradlew :app:verifyConsumerKeepRules # R8 keep rules survive minification +./gradlew publishToMavenLocal +./gradlew -p publishing-check verifyPublishedVariantResolution --refresh-dependencies +``` + +The last one resolves the published modules as an Android consumer would and fails on +platform-variant duplicates (an `okhttp-jvm` beside an `okhttp-android`). It reads `mavenLocal` +only, so publish locally first. All four run in CI on every pull request. + +`publish.sh` pins `GRADLE_OPTS` for its own Gradle invocations. Publishing drives Dokka across all +26 modules in a single daemon, which needs more metaspace than a typical personal +`~/.gradle/gradle.properties` allows — and user-level properties take precedence over the repo's, so +the project cannot set this itself. Unpinned, a release fails with a bare `Metaspace` error on an +arbitrary module. + +## Why one version for all modules + +Every module is published at `TOOLBOX_VERSION`, including modules whose code did not change. +Publishing only what changed is technically possible, but 14 of the 25 modules depend on a sibling +and those dependencies are published as hard version pins. Shipping `baserepo` alone would leave +consumers either silently not getting the fix (no BOM) or running `baserepo-paging` against a +`baserepo` it was never compiled with (BOM forcing the newer version). CI only ever builds one +coherent version set, so it cannot catch either. + +Republishing everything costs minutes of upload and no consumer risk. If a module ever genuinely +earns its own release cadence, split it into its own repository rather than versioning it +independently here. + +## Documentation + +- **README.md** and each module's README are consumer-facing. Version numbers in them are synced + automatically by the `UpdateReadmeVersions` Gradle task during sync — do not hand-edit versions. +- **This file** covers everything maintainer-facing. + +The JitPack → Central migration is finished; its history is in the +[1.9.0 release notes](https://github.com/appoly/AppolyDroid-Toolbox/releases/tag/1.9.0) and in the +commits around it, rather than in a plan document that would only go stale. diff --git a/README.md b/README.md index 8593869..bfcd3cb 100644 --- a/README.md +++ b/README.md @@ -37,36 +37,12 @@ dependencyResolutionManagement { > `com.github.appoly.AppolyDroid-Toolbox:BaseRepo` becomes `uk.co.appoly.droid:baserepo`, and > `AppolyDroid-Toolbox-bom` becomes `bom`. Keep your `jitpack.io` entry unless you have checked > that nothing else needs it — the toolbox no longer does, but other dependencies may, and a group -> like `com.github.projectdelta6` looks deceptively like the toolbox's old coordinates. +> like `com.github.projectdelta6` looks deceptively like the toolbox's old coordinates. The +> [1.9.0 release notes](https://github.com/appoly/AppolyDroid-Toolbox/releases/tag/1.9.0) cover the +> full move, including a transitive FlexiLogger bump that arrives with it. -### Testing an unreleased change - -Maven Central publishes only what is released, so there is no equivalent of JitPack's -build-any-branch behaviour. Two options replace it. - -**Install locally.** From a checkout of the branch you want to test: - -```bash -./scripts/publish.sh --local -``` - -That publishes every module to `~/.m2` — signed, and needing no upload credentials, only access to -the shared 1Password vault. Add `mavenLocal()` to the consuming project's repositories, ahead of -`mavenCentral()`. - -> Take `mavenLocal()` out again before committing, and before drawing any conclusion about a -> released version. A locally published build carries the same version string as the real one, so -> leaving it in means resolving your own artifacts while believing you are testing the release. - -**Publish a snapshot.** Snapshot versions go to Central's snapshot repository rather than the main -one, and need it adding explicitly: - -```kotlin -maven { url = uri("https://central.sonatype.com/repository/maven-snapshots/") } -``` - -Snapshots are changing modules, so pass `--refresh-dependencies` or Gradle will cache one for 24 -hours and you may silently keep testing a stale copy. +Need to test a change before it is released, or release the toolbox yourself? See +[CONTRIBUTING.md](CONTRIBUTING.md). ### Using the BOM (Bill of Materials) diff --git a/docs/maven-central-migration.md b/docs/maven-central-migration.md deleted file mode 100644 index 213a91f..0000000 --- a/docs/maven-central-migration.md +++ /dev/null @@ -1,260 +0,0 @@ -# Leaving JitPack - -Migration of AppolyDroid Toolbox publishing from JitPack to Maven Central. - -**Status as of 26 Aug 2026** — 1.9.0 released to Maven Central. Phase 5 (consumers) remains. -Current release: 1.8.3 on JitPack. 26 published modules. - -Two attempts to fix AAR source navigation were defeated by the same JitPack rewrite. -Publishing to Maven Central removes the rewriter rather than working around it. - ---- - -## Where this stands - -| Item | Then (24 Aug) | Now | -|------|---------------|-----| -| Namespace `uk.co.appoly` | TXT record absent from authoritative NS | **Verified** on the Central portal | -| Publishing configuration | 26 hand-written blocks, JitPack workarounds | **Landed** — `e48652e` | -| Release path | Planned as a tag-triggered CI job | **Superseded** — manual local run | -| 1.9.0 on Central | Not started | **Released** — 26 coordinates live, sources verified | - -1.9.0 is released and serving. All that remains is moving the four in-house consumers. - ---- - -## Why JitPack can't be fixed from our side - -JitPack strips the `-sources` classifier from the file entry inside a sources-typed variant -in the published `.module`. Both available shapes were tried against real JitPack builds: - -| Version | Shape | Result for AAR modules | | -|---------|-------|------------------------|---| -| 1.8.1 | Sources variant published | Variant's file entry stripped; Gradle requests a name that 404s and gives up silently | broken | -| 1.8.2 | No module metadata at all | Sources work — but POMs pin platform artifacts, so consumers get duplicate classes and cannot build | worse | -| 1.8.3 | Metadata restored, sources as POM classifier only | Builds fixed. Android Studio binds AAR sources from the variant, so with no variant it decompiles 25 of 26 modules | broken | -| 1.8.4-alpha01 | Sources variant restored for AARs | Stripped again. Android Studio requests ~25 non-existent files per sync, each timing out after 30s against a shared rate limit | worse | - -The two reachable states on JitPack are "no AAR source navigation" and "no AAR source -navigation, plus 25 doomed requests every sync". 1.8.3 is the better of those, and it is -where the library sits today. - -## What Central changes - -| | JitPack | Maven Central | -|---|---------|---------------| -| Metadata fidelity | Rewritten on serve | Byte-for-byte as uploaded | -| AAR source navigation | Not achievable | Works via the standard sources variant | -| Rate limits | Shared per-IP; has blocked us repeatedly | CDN-backed, no practical limit | -| First resolve | Builds on demand; minutes of latency | Already built and hosted | -| Release mutability | Retag and rebuild freely | **Immutable once released** | -| Credentials | None | Portal token + GPG signing key | - -Immutability cuts both ways. It is a discipline improvement, but the alpha-retag loop used -throughout the 1.8.3 work stops being possible. - -One incidental benefit: the whole `withSourcesJar()` question disappears. The publishing -plugin configures sources and javadoc for Android library variants itself, so the -hand-rolled sources-jar machinery was deleted rather than fixed. - ---- - -## Phases - -### 1. Portal and namespace — done - -- **Namespace verified.** `uk.co.appoly` shows as Verified on the Central portal under org - *Appoly*. The TXT record that was genuinely absent from `appoly.co.uk`'s authoritative - nameservers is now live. A verified namespace covers its subgroups, so - `uk.co.appoly.droid` needs no separate claim. -- **Signing key.** RSA 4096, no expiry, `2FF86BD312C381D279FA36F23F4AD175B7176969`, uid - `Appoly (Maven Central Signing)`. In 1Password under *Appoly Shared → Appoly Maven - Central Signing*, with both revocation certificates pre-generated and no copy on disk. - The key belongs to the organisation, not a person — anyone with vault access can publish. -- **Public half** on `hkps://keyserver.ubuntu.com`, verified by fetching it back into a - clean keyring rather than trusting `--send-keys`, whose exit code reports success - regardless. That keyserver only — which is the one Central checks. - -- **Portal token.** `portal-username` and `portal-token` are in the vault item alongside the - key, added 26 Aug. Field labels use hyphens and no spaces, which matters: a label - containing a space or parenthesis cannot be read through an `op://` reference at all — - that is why FlexiLogger's `private key (armored)` needs the JSON workaround its wrapper - carries, and why this script does not. - -`scripts/publish.sh` reads all five by exactly those names and fails clearly if any is -missing, so a misnamed field surfaces immediately rather than mid-upload. - -### 2. Publishing configuration — done (`e48652e`) - -All 26 modules carry `com.vanniktech.maven.publish` 0.37.0. Shared POM metadata — licence, -developers, SCM, URL — lives once in the root build, so a module declares only its own name -and description. No hand-written `publishing { }` block survives anywhere in the tree. - -Central rejects an incomplete POM, and a missing `developers` block is a hard rejection. - -- Sources-jar machinery and module-metadata workarounds **deleted**, not ported. They - existed only to fight the rewriter. -- The BOM's constraints are rewritten to the new coordinates. -- `publishing-check` is kept. Platform-variant leakage is a defect class independent of the - host, and it runs in CI on every PR. - -### 3. Release path — done, revised - -**Superseded.** This phase originally specified a tag-triggered GitHub Actions release job. -That is not the chosen path: **releases are run manually, locally, by a developer** for now. -No release CI, and no Maven Central secrets in the repository — repo-level Actions secrets -are empty, which is correct rather than missing. `.github/workflows/release.yml` has been -deleted to match. - -The wrapper lives **in the repo**, not a personal shell profile. FlexiLogger's equivalent -sits in one person's `~/.zshrc`, which is single-user by construction; `scripts/publish.sh` -reads the shared vault, so anyone with access can release. It offers `--dry-run` (every -gate, no publish, no tag) and `--local` (signed install to `~/.m2`). - -Gradle reads these only under the `ORG_GRADLE_PROJECT_` prefix with exact camelCase — the -script exports all five: - -| 1Password field | Environment variable | -|-----------------|----------------------| -| `portal-username` | `ORG_GRADLE_PROJECT_mavenCentralUsername` | -| `portal-token` | `ORG_GRADLE_PROJECT_mavenCentralPassword` | -| `private-key` | `ORG_GRADLE_PROJECT_signingInMemoryKey` | -| `key-id` | `ORG_GRADLE_PROJECT_signingInMemoryKeyId` | -| `passphrase` | `ORG_GRADLE_PROJECT_signingInMemoryKeyPassword` | - -```bash -export OP_ACCOUNT=appoly.1password.com # required: two accounts are registered -``` - -### 4. Publish 1.9.0 and verify — done - -A minor bump, not a patch — the coordinates change, so consumers must act. -`TOOLBOX_VERSION` is already `1.9.0`; no `1.9.0` tag exists and Central returns 404 for the -coordinates, so nothing has been claimed yet. - -Verification drops the parts that existed only to catch JitPack's rewriting. What remains is -the check 1.8.3's verification missed: confirm the served `.module` carries a sources variant -whose file entry ends `-sources.jar`, then confirm cmd+B lands on real source for an **AAR** -module — not just the one JVM module. - -**Immutability bites here.** A released version can never be re-uploaded or corrected. -Iterate with `--local` *before* the release, not after. - -**Released 26 Aug 2026.** Deployment `6dde9178-1023-4072-b6a4-d920bdae35bd`, 48/48 components -validated by Central, tag `1.9.0` → `e400b22`. Verified against the bytes Central actually -serves, not against the local build: - -- All 26 coordinates return their POM from `repo1.maven.org`. -- `baserepo` — an **AAR** module, the case that defeated JitPack twice — serves a - `releaseVariantReleaseSourcePublication` variant whose file entry is - `baserepo-1.9.0-sources.jar`, classifier intact. This is precisely what JitPack stripped. -- That sources jar downloads (19,981 bytes) and is a valid archive containing 14 `.kt` files, - so it is real source rather than an empty placeholder. - -The remaining check is human: cmd+B in a consuming project should land on source rather than -decompiled bytecode. Do it against an AAR module — 1.8.3 passed verification because only the -one JVM module was checked. - -The earlier `--dry-run` passed on 26 Aug: all five vault fields read, tests and coverage green, -consumer keep rules intact, 26 modules signed and installed to `~/.m2`, and the variant gate -clean (4 toolbox modules at 1.9.0, no `-jvm` duplicates). The only untested step is the -upload itself — reading the portal token proves it is fetchable, not that Central accepts it. - -Note the script pins `GRADLE_OPTS` for its own Gradle runs. Publishing drives Dokka across all -26 modules in a single daemon, which exceeds the 1 GiB metaspace a typical personal -`~/.gradle/gradle.properties` sets — and user-level properties beat the repo's, so the project -cannot fix this itself. Unpinned, the release fails with a bare `Metaspace` error on an -arbitrary module, naming the real cause nowhere. - -Owner: Bradley to run `./scripts/publish.sh`; one consumer session to verify. - -### 5. Migrate consumers - -All known consumers are in-house — WenWe, AssistantHood, AIM Capture, Accelerate-Android — -so a clean cut is simpler than dual-publishing. 1.8.3 stays available on JitPack indefinitely -as the last release there. - -Worth pairing deliberately: WenWe and AssistantHood exercise different module sets, so a -single green consumer proves little about the other. - -**WenWe migrated first, 26 Aug — passed.** Three findings that apply to the remaining three: - -1. **FlexiLogger moves 2.1.3 → 2.1.4.** The toolbox's own source is unchanged from 1.8.3 apart - from publishing config, but `81d5aac` bumped FlexiLogger, and it arrives transitively via - `baserepo`, `datehelperutil`, `connectivitymonitor` and `s3uploader`. A consumer that does - not declare a FlexiLogger version inherits the bump silently — and it is the layer logging - and crash reporting go through. A resolved-classpath diff on WenWe (949 → 948 coordinates) - showed this and the toolbox group/case swap were the *only* changes. - -2. **Do not remove `jitpack.io` by default.** The toolbox no longer needs it, but consumers may: - WenWe still resolves `com.github.projectdelta6:PrefsHelperBase` and `ComposeReorderable` - from JitPack. Removing it there would have broken the build. Check per consumer; assume it - stays until proven otherwise. - -3. **The `mavenLocal()` warning is per-consumer, not universal.** WenWe never declares it, so - the stale local 1.9.0 in `~/.m2/repository/uk/co/appoly` could not shadow anything. Check - before advising anyone to clear it. - -Also confirmed in WenWe: `okhttp-android` resolving beside `okhttp` is pre-existing (the normal -okhttp 5.x split, present on both sides of the diff), not a migration artefact; no `-jvm` beside -an `-android`; and `assembleStagingRelease` passes under R8 with no new missing-class warnings, -which is the honest duplicate-class test since dexing is what would fail. - -Owner: per-app sessions, roughly 30 minutes each. - ---- - -## One deliberate deviation: keyless local publishing - -This plan originally warned that once signing was configured, a bare -`./gradlew publishToMavenLocal` would fail with "no configured signatory", and called that -intended. It has since been relaxed, because it broke pull-request CI. - -PR CI runs `publishToMavenLocal` to feed the variant-resolution gate, and deliberately holds -no signing key — a pull-request build must not carry the release key. With -`signAllPublications()` unconditional, the gate died at `:bom:signMavenPublication` before -the check it exists to run. Signing is now enabled only when a key is present; the gate reads -module metadata and POMs and never looks at a signature. - -The safety property is preserved by a task-graph guard rather than by accident: any Maven -Central upload without a key fails at configuration time with an explicit refusal, instead of -uploading artifacts Central would reject in a release that cannot be undone. - -Side effect worth knowing: a keyless local publish leaves *stale* `.asc` files from earlier -signed runs sitting beside freshly-written unsigned artifacts in `~/.m2`. Listing the -directory looks signed. Timestamps are the only honest signal. - ---- - -## What consumers change - -| Before | After | -|--------|-------| -| `com.github.appoly.AppolyDroid-Toolbox:BaseRepo` | `uk.co.appoly.droid:baserepo` | -| `com.github.appoly.AppolyDroid-Toolbox:S3Uploader-Multipart` | `uk.co.appoly.droid:s3uploader-multipart` | -| `com.github.appoly.AppolyDroid-Toolbox:AppolyDroid-Toolbox-bom` | `uk.co.appoly.droid:bom` | -| `maven { url = "https://jitpack.io" }` | Removable, if nothing else needs it | - -## Risks and gotchas - -- **No more retagging.** Released versions are immutable. Pre-release iteration happens - through `--local`, which behaves differently from the alpha tags used during 1.8.3. -- **The key is on one keyserver only.** `keyserver.ubuntu.com`, which is what Central checks. - Do not assume it resolves elsewhere. -- **Coordinated cutover.** Four apps need updating; 1.8.3 and 1.9.0 will briefly coexist - across the estate. -- **Release runs on one machine.** With no CI release job, publishing depends on a - developer's local toolchain and an unlocked 1Password. That is the accepted trade for now, - not an oversight. - -### Dead ends — do not resurrect - -- **"GPG Appoly key"** in the Appoly *Employee* vault (2023) holds a username and passphrase - but no key material, and the key exists nowhere reachable. Abandoned earlier attempt. -- **"Nexus Staging"** in Appoly Shared (2020) belongs to Calum and is explicitly out of - scope. Unrelated to this migration. - ---- - -1.8.3 remains the current JitPack release and is unaffected; its heap, progress, notification -and duplicate-class fixes are all verified and unrelated to the sources question. diff --git a/scripts/publish.conf.example b/scripts/publish.conf.example new file mode 100644 index 0000000..7b7cc5d --- /dev/null +++ b/scripts/publish.conf.example @@ -0,0 +1,32 @@ +# Local publishing settings for scripts/publish.sh. +# +# Copy to `scripts/publish.conf` and edit. That filename is git-ignored: this repository is +# public, so deployment-specific details — which 1Password account and vault item hold the +# signing key — deliberately do not live in version control. +# +# cp scripts/publish.conf.example scripts/publish.conf +# +# None of this is required if you supply credentials through the environment instead. Export +# the five ORG_GRADLE_PROJECT_* variables (see CONTRIBUTING.md) and publish.sh never touches +# 1Password at all — that is the simplest path for a fork or for CI. + +# --- 1Password (optional) --------------------------------------------------- +# The item holding the signing key and Central portal token. Its fields must be named +# exactly: private-key, key-id, passphrase, portal-username, portal-token. +# +# Hyphens, no spaces: a field label containing a space or parenthesis cannot be read through +# an op:// reference at all. +# +# PUBLISH_VAULT_ITEM="op:///" + +# Needed where more than one 1Password account is registered on the machine; omitting it then +# fails with a misleading "no account found for filter". +# +# OP_ACCOUNT=".1password.com" + +# --- Coordinates (optional) ------------------------------------------------- +# Defaults suit this repository. A fork publishing under its own group sets these, and should +# also update the POM metadata and coordinates(...) in the root build.gradle.kts. +# +# PUBLISH_GROUP="uk.co.appoly.droid" +# PUBLISH_RELEASE_BRANCH="main" diff --git a/scripts/publish.sh b/scripts/publish.sh index e479cb4..6818a7c 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -5,20 +5,46 @@ # Publishes every module to Maven Central via the Vanniktech Maven Publish plugin. # # Unlike FlexiLogger — where a `flexipublish` function in a personal ~/.zshrc supplies the -# credentials and this script only consumes them — credential loading lives here, reading the +# credentials and this script only consumes them — credential loading can live here, reading the # SHARED Appoly vault. A wrapper in one person's shell profile is single-user by construction: -# anyone else with vault access still could not publish. There is nothing to install but the -# 1Password CLI. +# anyone else with vault access still could not publish. # # ./scripts/publish.sh publish to Maven Central, then tag # ./scripts/publish.sh --dry-run every gate, no publish and no tag # ./scripts/publish.sh --local signed install to ~/.m2, for testing a branch # +# CREDENTIALS — two supported paths, checked in this order: +# +# 1. Already in the environment. If the ORG_GRADLE_PROJECT_* variables below are exported, +# they are used as-is and 1Password is never invoked. This is the path for anyone who +# forked this repo, uses a different secret manager, or drives the script from CI. +# 2. 1Password. Otherwise the script reads them from the shared Appoly vault via the `op` +# CLI, which is how Appoly releases. +# +# This repo is public, so the 1Password path must not be the only one — it is unusable by +# anyone outside Appoly, and a release script that hard-fails for every fork is not much of a +# release script. +# +# CONFIGURATION — override by exporting, or in an optional git-ignored scripts/publish.conf: +# +# PUBLISH_GROUP Maven group being published (default: uk.co.appoly.droid) +# PUBLISH_RELEASE_BRANCH branch a real release expects (default: main) +# PUBLISH_VAULT_ITEM 1Password item, path 2 only (no default — see publish.conf.example) +# OP_ACCOUNT 1Password account, path 2 only (no default) +# set -euo pipefail -readonly VAULT_ITEM="op://Appoly Shared/Appoly Maven Central Signing" -readonly RELEASE_BRANCH="main" -readonly GROUP="uk.co.appoly.droid" +# An optional, git-ignored file for fork-specific settings, so a fork needs no diff against +# this script to publish under its own coordinates. +CONF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/publish.conf" +# shellcheck disable=SC1090 +[[ -f "$CONF" ]] && source "$CONF" + +# No default: the vault coordinates are deployment-specific and this repository is public. +# Set PUBLISH_VAULT_ITEM in scripts/publish.conf — see scripts/publish.conf.example. +readonly VAULT_ITEM="${PUBLISH_VAULT_ITEM:-}" +readonly RELEASE_BRANCH="${PUBLISH_RELEASE_BRANCH:-main}" +readonly GROUP="${PUBLISH_GROUP:-uk.co.appoly.droid}" # Pin the daemon's memory for this script's Gradle invocations rather than inheriting whatever # each developer keeps in ~/.gradle/gradle.properties — which takes precedence over the repo's @@ -29,9 +55,9 @@ readonly GROUP="uk.co.appoly.droid" export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=2048m -Dfile.encoding=UTF-8" -# Some machines have two 1Password accounts registered; omitting this fails with a misleading -# "no account found for filter". -export OP_ACCOUNT="${OP_ACCOUNT:-appoly.1password.com}" +# Only exported when configured. Where more than one 1Password account is registered, omitting +# it fails with a misleading "no account found for filter", so publish.conf should set it. +[[ -n "${OP_ACCOUNT:-}" ]] && export OP_ACCOUNT RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; BOLD=$'\033[1m'; NC=$'\033[0m' info() { echo "${GREEN}[INFO]${NC} $1"; } @@ -53,8 +79,10 @@ Usage: ./scripts/publish.sh [--local|--dry-run] --dry-run, -n Run every gate — clean, tests, coverage, keep rules, metadata check — but do not publish or tag. -Credentials are read from 1Password (Appoly Shared -> Appoly Maven Central -Signing). The 1Password desktop app must be unlocked, with CLI integration on. +Credentials come either from the environment (export the five ORG_GRADLE_PROJECT_* +variables) or from 1Password. For the 1Password path, copy +scripts/publish.conf.example to scripts/publish.conf and set PUBLISH_VAULT_ITEM; +the desktop app must be unlocked with CLI integration on. USAGE exit 0 ;; *) fail "Unknown argument: $arg"; echo "Try --help" >&2; exit 1 ;; @@ -94,42 +122,92 @@ fi # ------------------------------------------------------------ credentials --- -# Only these five names are read by Gradle. Aliases like MAVEN_CENTRAL_USERNAME are what the CI -# workflow calls its secrets, but Gradle never reads them directly — accepting one here would pass -# this check and then fail obscurely at signing or upload. -read_field() { - local value - if ! value=$(op read "$VAULT_ITEM/$1" 2>/dev/null) || [[ -z "$value" ]]; then - fail "Could not read '$1' from 1Password." - fail "Is the desktop app unlocked, with CLI integration enabled?" +# Only these five names are read by Gradle. Aliases like MAVEN_CENTRAL_USERNAME are what a CI +# workflow might call its secrets, but Gradle never reads them directly — accepting one here would +# pass this check and then fail obscurely at signing or upload. +readonly SIGNING_VARS=( + ORG_GRADLE_PROJECT_signingInMemoryKey + ORG_GRADLE_PROJECT_signingInMemoryKeyId + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword +) +readonly UPLOAD_VARS=( + ORG_GRADLE_PROJECT_mavenCentralUsername + ORG_GRADLE_PROJECT_mavenCentralPassword +) + +# Signing is needed in EVERY mode, dry run included, even though the root build skips signing when +# no key is present (so that keyless PR CI can run publishToMavenLocal for the metadata gate). A dry +# run that skipped signing would stop exercising the one step a real release cannot survive failing, +# and --local exists precisely to test the signed artifacts. The upload token is not needed for a +# local install, but IS read for a dry run: a dry run exists to fail before the immutable step does, +# and a missing or misnamed credential is exactly what it should catch. +needed=("${SIGNING_VARS[@]}") +[[ "$MODE" != "local" ]] && needed+=("${UPLOAD_VARS[@]}") + +missing=() +for var in "${needed[@]}"; do + [[ -n "${!var:-}" ]] || missing+=("$var") +done + +if [[ ${#missing[@]} -eq 0 ]]; then + # Path 1 — supplied by the environment. Nothing to load, and no 1Password dependency. + info "Using credentials already present in the environment." + export "${needed[@]}" +else + # Path 2 — 1Password. Refuse to load a partial set from the vault on top of a partial set from + # the environment: mixing sources silently is how you sign a release with one identity and + # upload it with another. + if [[ ${#missing[@]} -ne ${#needed[@]} ]]; then + fail "Some publishing credentials are set in the environment and some are not." + fail "Set all of them, or none and let 1Password supply them. Missing: ${missing[*]}" exit 1 fi - printf '%s' "$value" -} - -command -v op >/dev/null || { fail "1Password CLI not found. Install it and enable desktop-app integration."; exit 1; } - -# Signing is read in EVERY mode, dry run included, even though the root build now skips signing -# when no key is present (so that keyless PR CI can run publishToMavenLocal for the metadata gate). -# A dry run that skipped signing would stop exercising the one step a real release cannot survive -# failing, and --local exists precisely to test the signed artifacts. Only the upload token is -# release-only. -info "Reading signing credentials from 1Password..." -ORG_GRADLE_PROJECT_signingInMemoryKey=$(read_field private-key) -ORG_GRADLE_PROJECT_signingInMemoryKeyId=$(read_field key-id) -ORG_GRADLE_PROJECT_signingInMemoryKeyPassword=$(read_field passphrase) -export ORG_GRADLE_PROJECT_signingInMemoryKey ORG_GRADLE_PROJECT_signingInMemoryKeyId ORG_GRADLE_PROJECT_signingInMemoryKeyPassword - -# Read in dry-run mode too, not just release. A dry run exists to fail before the immutable -# step does, and a missing or misnamed portal field is exactly the kind of thing it should -# catch — reading it here costs nothing and means the first real release is not the first -# time these two fields have ever been fetched. Nothing uploads in dry-run mode regardless. -# --local skips them: it needs no upload credentials at all. -if [[ "$MODE" != "local" ]]; then - info "Reading Maven Central token from 1Password..." - ORG_GRADLE_PROJECT_mavenCentralUsername=$(read_field portal-username) - ORG_GRADLE_PROJECT_mavenCentralPassword=$(read_field portal-token) - export ORG_GRADLE_PROJECT_mavenCentralUsername ORG_GRADLE_PROJECT_mavenCentralPassword + + if [[ -z "$VAULT_ITEM" ]]; then + fail "No publishing credentials found, and no 1Password item is configured." + fail "" + fail "Either export these and re-run:" + for var in "${needed[@]}"; do fail " $var"; done + fail "" + fail "Or copy scripts/publish.conf.example to scripts/publish.conf and set" + fail "PUBLISH_VAULT_ITEM to your own vault item." + exit 1 + fi + + if ! command -v op >/dev/null; then + fail "No publishing credentials found, and the 1Password CLI is not installed." + fail "" + fail "Either export these and re-run:" + for var in "${needed[@]}"; do fail " $var"; done + fail "" + fail "Or install the 1Password CLI and enable desktop-app integration. Forks will want" + fail "the first option, and can set PUBLISH_GROUP in scripts/publish.conf to publish" + fail "under their own coordinates." + exit 1 + fi + + read_field() { + local value + if ! value=$(op read "$VAULT_ITEM/$1" 2>/dev/null) || [[ -z "$value" ]]; then + fail "Could not read '$1' from $VAULT_ITEM." + fail "Is the desktop app unlocked, with CLI integration enabled?" + exit 1 + fi + printf '%s' "$value" + } + + info "Reading signing credentials from 1Password..." + ORG_GRADLE_PROJECT_signingInMemoryKey=$(read_field private-key) + ORG_GRADLE_PROJECT_signingInMemoryKeyId=$(read_field key-id) + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword=$(read_field passphrase) + export ORG_GRADLE_PROJECT_signingInMemoryKey ORG_GRADLE_PROJECT_signingInMemoryKeyId ORG_GRADLE_PROJECT_signingInMemoryKeyPassword + + if [[ "$MODE" != "local" ]]; then + info "Reading Maven Central token from 1Password..." + ORG_GRADLE_PROJECT_mavenCentralUsername=$(read_field portal-username) + ORG_GRADLE_PROJECT_mavenCentralPassword=$(read_field portal-token) + export ORG_GRADLE_PROJECT_mavenCentralUsername ORG_GRADLE_PROJECT_mavenCentralPassword + fi fi # No unsetting needed: these live in this script's process, not the caller's shell. From 8b6423c5a2ee5d750b1209fa29d31dfcd8ffeb7f Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 26 Aug 2026 15:51:26 +0100 Subject: [PATCH 2/3] docs: CLAUDE.md still said the library is published via JitPack The first line an agent reads about this project described the publishing setup it no longer uses. Also notes the repository is public, since that is the assumption behind the vault coordinates living outside version control. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c677fde..3c44c5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -AppolyDroid Toolbox is a multi-module Android library published via JitPack. It provides utilities for API handling, UI state management, Compose pagination, S3 uploads, and date utilities. +AppolyDroid Toolbox is a multi-module Android library published to Maven Central under `uk.co.appoly.droid`. It provides utilities for API handling, UI state management, Compose pagination, S3 uploads, and date utilities. The repository is public, so treat anything committed here as published. ## Build Commands From 461502248727966436d94231e112cdc3a2ae7d37 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 26 Aug 2026 15:56:35 +0100 Subject: [PATCH 3/3] docs: say where maintainers get their publish.conf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config moved out of the public repo with nowhere stated to find it, which would have left the next person to release guessing. It lives in the same 1Password item as the credentials it points at. Anyone who can release already holds that item, so the audience for the config is exactly the set of people who can read it — no second system to grant access to, and no way for the pointer to drift from what it points at. Co-Authored-By: Claude Opus 5 (1M context) --- CONTRIBUTING.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54ffa17..523fc7c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,9 +59,20 @@ The script resolves credentials in two ways, in this order: 1. **Already in the environment.** If all of the variables below are exported, they are used as-is and 1Password is never invoked. This is the path for forks, other secret managers, and CI. 2. **1Password.** Otherwise they are read from a vault item via the `op` CLI. Which item is - deployment-specific and deliberately not in version control: copy - `scripts/publish.conf.example` to `scripts/publish.conf` and set `PUBLISH_VAULT_ITEM`. - Appoly maintainers can get the value from the team. + deployment-specific and deliberately not in version control, since this repository is public. + +**Appoly maintainers, one-time setup.** The config lives in the same 1Password item as the +credentials — search the shared vault for *Maven Central* and copy its `publish-conf` field into +`scripts/publish.conf`. It is kept there rather than in a wiki because anyone who can release +already has that item: the audience for the config is exactly the set of people who can read it, +and it cannot drift out of sync with what it points at. + +```bash +op read "op:////publish-conf" > scripts/publish.conf +``` + +**Everyone else:** copy `scripts/publish.conf.example` to `scripts/publish.conf` and set +`PUBLISH_VAULT_ITEM` to your own item — or skip 1Password entirely and export the five variables. Gradle reads these only under the `ORG_GRADLE_PROJECT_` prefix, with exact camelCase: