Repository navigation
Make the Publish workflow reliable - #814
Conversation
- Serialize `Publish` runs per repository (`concurrency` with `queue: max`), so duplicate runs of one push no longer race for the same immutable files. - Add `publicationState`, run before anything is built. A run of a commit that already published its version ends green with nothing uploaded; a version partially published, published from another commit, or published from a commit that cannot be told fails before any upload. It checks every file of each publication in each remote repository, GitHub Packages included, read with `GITHUB_TOKEN`. - Retry uploads for longer (5 attempts from 5 s apart) and skip the `.sha256`/`.sha512` uploads, which neither Gradle nor Maven reads. - Drop the `Decrypt CloudRepo credentials` step, which failed runs although nothing publishes to CloudRepo. - Read registries through `RemoteFile`: timeouts, retries, and redirects that keep the token at its origin. `checkVersionIncrement` uses it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Catch only the exceptions resolving a redirect `Location` can throw, instead of every `Throwable`, and name each `Presence` in `when`. - Name the arguments of the spec's `presenceOf` and `holding` calls, and catch only `IllegalArgumentException` in the test registry. - Tell in the `RemoteFile` docs that a rate-limit `403` is retried and that `exists` does not follow a redirect to another origin; cover the rate-limit retry with a test. - Word the pruning hint so it no longer contradicts the advice to bump the version, and fix the wording of a few workflow comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 21b6af44e3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
🟡 Changes recommended
SBOM validation can incorrectly accept conflicting or unconfirmed publications as already published.
1 open issue
What changed in this PR
Improves the shared publishing workflow’s reliability by serializing uploads and checking remote publication state before building.
Changes:
- Adds publication-state checks and duplicate-run skipping.
- Introduces retrying registry reads and longer upload retries.
- Removes obsolete credential handling and adds tests.
| File | Description |
|---|---|
| README.md | Documents duplicate-run behavior. |
| buildSrc/src/test/kotlin/io/spine/gradle/publish/RemoteFileSpec.kt | Tests retries, redirects, and authentication. |
| buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationStateSpec.kt | Tests publication-state decisions. |
| buildSrc/src/test/kotlin/io/spine/gradle/publish/PublicationStateIgTest.kt | Exercises publishing against loopback registries. |
| buildSrc/src/main/kotlin/io/spine/gradle/repo/Repository.kt | Exposes distinct repository targets. |
| buildSrc/src/main/kotlin/io/spine/gradle/publish/SpinePublishing.kt | Registers the publication-state task. |
| buildSrc/src/main/kotlin/io/spine/gradle/publish/RemoteFile.kt | Adds bounded, retrying registry reads. |
| buildSrc/src/main/kotlin/io/spine/gradle/publish/PublishingExts.kt | Shares publication naming helpers. |
| buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationState.kt | Checks remote completeness and commit identity. |
| buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationSbom.kt | Exposes the SBOM extension constant. |
| buildSrc/src/main/kotlin/io/spine/gradle/publish/PublicationChecksums.kt | Reuses publication filename helpers. |
| buildSrc/src/main/kotlin/io/spine/gradle/publish/MavenMetadata.kt | Uses retrying metadata reads. |
| buildSrc/src/main/kotlin/io/spine/gradle/publish/IncrementGuard.kt | Uses the shared repository-path helper. |
| buildSrc/src/main/kotlin/io/spine/gradle/publish/GitHubPackages.kt | Adds repository identification helpers. |
| buildSrc/src/main/kotlin/io/spine/gradle/publish/CheckVersionIncrement.kt | Uses shared repository targets. |
| .github/workflows/publishing.yml | Serializes publishing, checks state, and tunes retries. |
| .agents/tasks/publishing-reliability.md | Records implementation and verification details. |
Review effort: Balanced
`publicationState` checks the Maven repositories only, and a `published` state skips the whole `publish` invocation, so the GitHub Pages update and an npm publication wired into `publish` are not re-run. Say so in the workflow and in the notice of such a run, instead of letting a green re-run imply they completed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`publicationState` read the SBOM of a publication from one destination only, so a copy in GitHub Packages uploaded by another commit went unnoticed, and a copy naming no commit (`unknown`, as built outside GitHub Actions) was accepted when another publication named this commit. Read the SBOM from every destination: any copy naming another commit is a collision, and any copy naming none leaves the version unconfirmed. Publications without an SBOM, such as plugin markers, stay exempt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`GitHubPackages.isRepository` built a `Repository` with an empty GitHub repository name only to call `name(snapshots)`, which also read `GITHUB_ACTOR` and composed a meaningless URL. Move the `-snapshots` suffix rule into `Repository.publishingName`, used by `name(snapshots)` and by `isRepository` alike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5db35fbdcc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A `429` or a rate-limit `403` with a `Retry-After` longer than the 1- and 2-second backoff used up all three attempts before the registry allowed another request. `RemoteFile` now waits at least the delay the header gives, in seconds or as an HTTP date, capped at a minute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f16f163475
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`repoName` in `GitHubPackages.repository` and `PublishingRepos.gitHub` read too close to the `GitHubPackages.repositoryName` constant, the name of the Gradle repository. Call the GitHub repository name, such as `core-jvm`, `slug`, and document it. Consumers pass it positionally, so their calls are unaffected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`RemoteFile.exists` took a redirect to another origin for proof that the file exists. A server redirecting every request, e.g., from HTTP to HTTPS or to a canonical host, made missing files look present, so a version never published looked partially published. Follow every redirect and judge the answer at its end, without the `Authorization` header on another origin; Artifact Registry and GitHub Packages answer `HEAD` without redirecting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 29e2fdbfea
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
An already published re-run decrypted the Git SSH, GCS, Artifact Registry and Plugin Portal credentials before `publicationState` could tell it had nothing to upload, so a missing or rotated secret failed it. Run the check right after the setup, as it needs only `GITHUB_TOKEN`, and prepare the credentials only when the version is unpublished. The consumer's build that the check runs no longer finds any publishing secret on disk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`.agents/guidelines/testing.md` and the `kotlin-jvm-tester` skill put `@Nested inner class` on one line and the class name on the next, with backticks only for a multi-word name or a Kotlin hard keyword. Apply the layout to every `@Nested` class under `buildSrc/src/test`, 34 of the 38, and drop the needless backticks from `keep` in `ArtifactComponentsSpec`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Why
41 of 261
Publishruns failed between 2026-06-01 and 2026-10-21. Among those whose logs survive:core-jvmcommit. Artifact Registry answers400to a PUT of a file that already exists, so three of them failed on the first file the winner had stored.tool-basegot429 Too Many Requestson parallel uploads. Gradle retries a PUT only 3 times within about 3 seconds, and never retries a400.Decrypt CloudRepo credentialsfailed 7 runs (delivery, elastic), although CloudRepo is deprecated and nothing publishes to it.Every merge publishes a new immutable version, so neither a duplicate run nor a re-run of a partly published version can ever succeed.
What changes
publishjob ofpublishing.ymlgets aconcurrencygroup withqueue: max: runs wait instead of racing, and a waiting run is not cancelled by a newer one.publicationState, run before anything is built. A new root task looks for every file of each publication in each remote repository it is published to (Artifact Registry, and GitHub Packages read withGITHUB_TOKEN). It writesunpublished, orpublishedwhen everything is complete and the published SBOMs name this very commit — a duplicate run or a re-run, which then ends green with nothing uploaded or attested. Any other state fails before a single upload: a gap in any repository (a failed earlier run, or GitHub Packages pruning old versions), a version published from another commit, or one whose commit cannot be determined.-Dorg.gradle.internal.network.retry.max.attempts=5,…initial.backOff=5000(5 + 10 + 20 + 40 s between attempts) and-Dorg.gradle.internal.publish.checksums.insecure=true, which skips the.sha256/.sha512files nobody reads.RemoteFilereads the registries with timeouts and retries (server errors,408,429, GitHub's rate-limit403), stops on other client errors, and follows redirects without carrying the token to another origin.checkVersionIncrementreadsmaven-metadata.xmlthrough it too.Repository.targets(),MavenPublication.publishedName()andrepositoryPathinPublishingExts.kt,GitHubPackages.isRepository().Verification
./gradlew :buildSrc:build detekt: green, 284 tests. New:PublicationStateSpec,RemoteFileSpec, andPublicationStateIgTest, which really publishes to a loopback registry with a GitHub-Packages-like area (token required, downloads redirected to another origin).core-jvm'sspine-core:2.0.0-SNAPSHOT.576→publishedfroma67ffd4c(also when read from GitHub Packages only, through its real redirect); another commit → collision;.565→ partial (pruned from GitHub Packages); an unpublished version →unpublished; no token or a wrong one → a clear failure.actionlintpasses except forconcurrency.queue, which GitHub supports since 2026-05-07 but actionlint 1.7.12 does not know yet (concurrency: Add support forqueuekey rhysd/actionlint#654).Notes
./config/pull: the pinnedpublishing.ymland thebuildSrcholdingpublicationStatemove together.publishApiDocs, apublish-api-docs.ymlworkflow) is being done in a separate branch, and propagated by asummitwave..agents/tasks/publishing-reliability.md.🤖 Generated with Claude Code