Skip to content

Implement the apply goal as a Maven-native port of prevent-overwrites.sh - #1

Open
Jardo-51 wants to merge 14 commits into
mainfrom
feature/initial-plugin-implementation
Open

Jardo-51 wants to merge 14 commits into
mainfrom
feature/initial-plugin-implementation

Conversation

@Jardo-51

@Jardo-51 Jardo-51 commented Sep 4, 2026

Copy link
Copy Markdown
Member

Initial implementation of com.jardoapps:pao-maven-plugin:0.1.0 — a Maven-native port of the prevent-artifact-overwrites shell script.

Approach

A single apply goal, run as its own invocation before the publishing build:

mvn -B com.jardoapps:pao-maven-plugin:0.1.0:apply
mvn -B deploy

Two invocations are a constraint, not a preference: Maven reads and interpolates every POM before any mojo executes, so a version written during a build does not change what that same build deploys. Running as a lifecycle extension would allow a single invocation, but it would then execute on every developer mvn call and every IDE import — the compile-time version rewriting the original project deliberately avoids.

No POM changes are required. Every environment variable of the shell version maps to a pao.* property, and .prevent-overwrites.conf is supported unchanged, so the existing CI interface carries over by swapping one line.

ApplyMojo is parameter plumbing only; the behaviour lives in PreventOverwritesRunner, which works on plain types and can be driven directly from tests without the Maven testing harness.

Rather than depending on versions-maven-plugin internals, PomDocument scans a pom.xml into elements carrying their source offsets and splices edits into the original text. Formatting, comments and attribute quoting survive untouched, and comments/CDATA/quoted attributes are skipped so a <version> inside a comment is never rewritten.

Bugs in the shell version this fixes

Each was reproduced against prevent-overwrites.sh before being fixed here.

  1. Multi-module reactors break. enforce_branch_version only rewrites a <version> outside <parent>, never a module's <parent><version>. On a two-module project the child kept 1.2.3-SNAPSHOT while the aggregator moved, and mvn validate then failed with Non-resolvable parent POM. The plugin moves parent references across the reactor in step with the project version.
  2. Two-digit patch numbers are truncated. The prefix pattern matches a single patch digit, so 1.2.10-feature-x-SNAPSHOT strips to 1.2.1-SNAPSHOT — a silently wrong version.
  3. Pin validation was working around sed. Pinned values were restricted to a safe character set only because they flowed into a sed replacement. With structured editing the check is now the actual requirement: that a pin is revertible.

A fourth was caught by these tests during development — an ambiguous -rc pattern that read 1.2.3-rc.4-SNAPSHOT as base 1.2.3 plus suffix rc.4, which would have dropped the qualifier on a core branch. Fixed with possessive quantifiers.

Intentional behaviour changes

Both documented in the README:

  • Property-indirected versions are followed. <version>${revision}</version> updates the <revision> property rather than replacing the reference. Undefined properties are left alone with a warning.
  • Every config row is validated, not just rows matching the current branch, so a typo fails on the first run instead of lying dormant until that branch is next built.

Testing

  • 71 unit tests. The twelve cases from the shell test suite are ported and compare output to the original fixture files byte for byte, which is what pins down formatting preservation.
  • 5 integration tests (bash it/run-integration-tests.sh) cover what only a real Maven invocation exercises: parameter binding, reactor collection from the session, and git operations against real repositories. The round-trip test asserts mvn validate still resolves after the version change — the check that fails on the shell version.

Not included

Maven Central distribution (signing, staging, a release profile) is not set up, so the plugin currently resolves only from a local mvn install or an internal repository.

🤖 Generated with Claude Code

Adds com.jardoapps:pao-maven-plugin:0.1.0 with a single "apply" goal that
manages branch-specific SNAPSHOT versions: on a feature branch it appends the
branch name to the project version, and on a core branch it strips that suffix
back off, from the project version and from dependency versions alike.

The goal runs as its own invocation rather than binding into the build, because
Maven reads and interpolates every POM before any mojo executes, so a version
written during a build would not change what that build deploys.

ApplyMojo is parameter plumbing only; the behaviour lives in
PreventOverwritesRunner, which works on plain types so it can be driven directly
from tests. Every environment variable of the shell version maps to a pao.*
property, and the .prevent-overwrites.conf format is supported unchanged.

Rather than depending on versions-maven-plugin internals, PomDocument scans a
pom.xml into elements carrying their source offsets and splices edits into the
original text, leaving formatting, comments and attribute quoting untouched.
Comments, CDATA and quoted attributes are skipped, so a <version> inside a
comment is never rewritten.

Working from Maven's model rather than grep and sed fixes three defects in the
shell implementation:

- Multi-module reactors broke, because only a <version> outside <parent> was
  rewritten and module <parent><version> references were left pointing at a
  version that no longer existed. Parent references across the reactor now move
  with the project version.
- A two-digit patch number was truncated: the prefix pattern matched a single
  patch digit, so 1.2.10-feature-x-SNAPSHOT stripped to 1.2.1-SNAPSHOT.
- Pinned values needed a restricted character set purely because they flowed
  into a sed replacement; with structured editing the check is now that a pin is
  revertible, which is the actual requirement.

Two behaviours differ from the shell version on purpose, both documented:
versions written as ${property} are followed to the property that defines them
rather than having the reference overwritten, and every configuration row is
validated even when it targets another branch, so typos surface on the first run.

Covered by 71 unit tests, including the twelve cases ported from the shell test
suite, which compare output to the original fixtures byte for byte. Mojo wiring,
reactor collection and the git operations are covered by it/run-integration-tests.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@Jardo-51 Jardo-51 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review summary

Severity Count
CRITICAL 1
HIGH 1
MEDIUM 5
LOW 6

Overall assessment: needs rework

The design is clean and the splice-based POM editing is a genuinely good call - formatting, comments and ${property} indirection all survive, and the test suite (71 tests, green locally) is thorough for the paths it covers. The blocker is a gap in the enforce path: when the project version changes, <dependency> entries pointing at other reactor modules are not moved with it, so a multi-module project ends up building against the shared x.y.z-SNAPSHOT from the repository - precisely the artifact this plugin exists to protect. Below that, the 120s git timeout in CommandLineGitClient can never fire, and git commit -a commits more than the plugin changed.

General notes

  • No CI workflow. The repository has no .github/ directory, so neither mvn test nor it/run-integration-tests.sh runs on a PR, and the GitHub Actions snippet the README recommends to users is itself never exercised. Worth adding before this merges, given the plugin's whole job is to be correct inside someone else's pipeline.
  • Integration coverage is thin on the paths most likely to break. it/run-integration-tests.sh covers the round trip, idempotence, env detection, an invalid pin and skip. Nothing exercises core-branch dependency stripping, ${revision} versions, or per-branch pinning against a real Maven invocation, and no IT covers the multi-module dependency case described in the CRITICAL finding.
  • Documentation is accurate for what the code does. The one gap: the README's "Multi-module projects" section states the whole reactor is handled in one run and mentions only <parent><version>; whichever way the CRITICAL finding is resolved, that paragraph needs to say what happens to inter-module <dependency> versions.

Verification notes: findings 1, 5 and 8 were reproduced by driving PreventOverwritesRunner against scratch reactors; the rest are from reading. mvn -o test passes at b56dd9f.

Comment thread src/main/java/com/jardoapps/pao/PreventOverwritesRunner.java
Comment thread src/main/java/com/jardoapps/pao/git/CommandLineGitClient.java Outdated
Comment thread src/main/java/com/jardoapps/pao/git/CommandLineGitClient.java Outdated
Comment thread src/main/java/com/jardoapps/pao/PreventOverwritesRunner.java Outdated
Comment thread src/main/java/com/jardoapps/pao/BranchVersions.java Outdated
Comment thread src/main/java/com/jardoapps/pao/config/PinConfigParser.java Outdated
Comment thread src/main/java/com/jardoapps/pao/config/PinConfigParser.java Outdated
Comment thread it/run-integration-tests.sh Outdated
Comment thread src/main/java/com/jardoapps/pao/GlobMatcher.java Outdated
Comment thread src/test/java/com/jardoapps/pao/git/FakeGitClient.java Outdated
changeProjectVersion rewrote only <project><version> and <parent><version>, so a
module depending on a sibling by literal version kept pointing at the old one and
resolved it from the repository - the branch-agnostic snapshot the plugin exists
to avoid. The reactor fixture gains an "app" module depending on "core", which
reproduces it.

Addresses review comment from @Jardo-51 on src/main/java/com/jardoapps/pao/PreventOverwritesRunner.java:236.
…ntials

readAllBytes() blocked until git exited, so waitFor always ran against a finished
process and the 120s guard could never fire. The output is now drained on a
separate thread so waitFor is the thing that bounds the call. Reading after
waitFor would have deadlocked on output larger than the pipe buffer.

Closing the child's stdin and setting GIT_TERMINAL_PROMPT=0 makes a push to an
https remote with no usable credential helper fail immediately instead of
blocking on a username prompt.

Addresses review comment from @Jardo-51 on src/main/java/com/jardoapps/pao/git/CommandLineGitClient.java:101.
git commit -a staged every modified tracked file, so anything an earlier pipeline
step or a developer had touched was swept into a commit labelled "Switched to
branch-specific version." and pushed. PomEditSession.save() already returns the
exact files it wrote, so those paths are now passed down to git add/commit as an
explicit pathspec.

That also removes the hasUncommittedChanges/commit -a mismatch: git status
--porcelain reported untracked files that commit -a would then not stage, so a
tree whose only change was untracked passed the guard and failed with exit 1.
The guard is gone; an empty file list is the condition that matters.

Addresses review comment from @Jardo-51 on src/main/java/com/jardoapps/pao/git/CommandLineGitClient.java:54.
configureUser ran unconditionally at the top of every run, so a core-branch run
that changed nothing still left user.name=ci-bot in the repository's .git/config -
permanently, in a developer's own clone - and a run outside a working copy died on
`git config --local` before it had decided whether it needed to commit at all.

The identity now travels with the commit as `git -c user.name=... -c
user.email=... commit`, so it applies to that commit only and no git command runs
until there is something to commit.

Addresses review comment from @Jardo-51 on src/main/java/com/jardoapps/pao/PreventOverwritesRunner.java:80.
For <version>1.2.3</version> on a feature branch the fallback produced
1.2.3-feature-x-SNAPSHOT, and the trip back on a core branch produced
1.2.3-SNAPSHOT - a lossy round trip that converted a released project into a
snapshot one without asking. The existing "does not match <base>-<suffix>-SNAPSHOT"
warning did not fire, because the derived version was itself valid.

withBranch now returns Optional and is empty for a non-snapshot version;
enforceBranchVersion logs why it is leaving the version alone and returns. Also
documents the behaviour in the README's version format section.

Addresses review comment from @Jardo-51 on src/main/java/com/jardoapps/pao/BranchVersions.java:75.
On GitHub Actions pull_request / pull_request_target events GITHUB_REF is
refs/pull/<n>/merge, so GITHUB_REF_NAME is the synthetic '<n>/merge'. That derived
a version nobody can merge back and then pushed to a ref the forge rejects.
GITHUB_HEAD_REF carries the real source branch and is set only for pull-request
events, so it is checked first; on push events it is present but empty and the
existing blank check skips it.

CI_MERGE_REQUEST_SOURCE_BRANCH_NAME gets the same treatment, since GitLab merged
results pipelines set CI_COMMIT_REF_NAME to refs/merge-requests/<n>/merge.

Addresses review comment from @Jardo-51 on src/main/java/com/jardoapps/pao/BranchDetector.java:18.
A pom declaring ISO-8859-1 and containing any non-ASCII byte - an accented name in
<developers>, a word in <description> - made readString throw
MalformedInputException, failing the goal on a file Maven parses without
complaint. save() had the mirror problem: it always wrote UTF-8, so the declared
encoding and the actual bytes could drift apart for content the plugin never
touched.

The prolog is now read out of the raw bytes as ISO-8859-1, which maps every byte
to a character and cannot itself fail, and the charset it names is used for both
the read and the write. Absent or unparseable prolog means UTF-8, as before.

Addresses review comment from @Jardo-51 on src/main/java/com/jardoapps/pao/pom/PomDocument.java:51.
RunResult.projectVersion was filled from root.version(), read before any rewrite,
so a field whose name reads as "the version this run produced" reported the stale
value. enforceBranchVersion and removeBranchVersion now return the version they
leave the project at, and the pre-run value is kept as previousProjectVersion.

Also exposes it as a project-version output alongside changes-made, so CI can pick
up the version the build will publish under.

Addresses review comment from @Jardo-51 on src/main/java/com/jardoapps/pao/PreventOverwritesRunner.java:104.
A null file logged "No config file at 'null'". The two cases now get their own
message, so someone who expected their pins to apply sees that the file the plugin
looked for is not there, rather than a line about null.

Addresses review comment from @Jardo-51 on src/main/java/com/jardoapps/pao/config/PinConfigParser.java:47.
Only too-few columns were rejected; extra ones were silently dropped. A stray
space inside a value - `1.2.3 -f1-SNAPSHOT` - parsed as four columns and pinned
the project to `1.2.3`, failing later with a confusing "invalid pinned version",
or succeeding with the wrong pin when the truncated value happened to be valid on
its own. stripComment already removes trailing comments, so a legitimate fourth
column cannot exist.

Addresses review comment from @Jardo-51 on src/main/java/com/jardoapps/pao/config/PinConfigParser.java:71.
PASSED was incremented at the end of every test function regardless of whether
fail had been called inside it, so a run with broken assertions reported the same
test in both columns. Each function now brackets itself with begin_test/end_test,
and end_test only counts a pass when the failure count has not moved. The exit
status was already correct; this is about the summary being readable.

Addresses review comment from @Jardo-51 on it/run-integration-tests.sh:117.
The class body went into the Java regex verbatim, so bash-valid classes broke or
changed meaning: []] - bash's literal ']' - became the empty class [] and threw
PatternSyntaxException, [a&&b] silently became a regex intersection matching
nothing, and a backslash meant something else entirely.

The body is now copied with '\', '[', ']', '&' and '^' escaped, a ']' in the first
position is recognised as a literal, and an unmatched '[' stays literal as in bash.
Since these patterns come from pao.coreBranches and the config file's branch
column, a pattern that still cannot compile is reported as a PaoException naming
the pattern rather than an unadorned PatternSyntaxException.

Addresses review comment from @Jardo-51 on src/main/java/com/jardoapps/pao/GlobMatcher.java:36.
FakeGitClient recorded the identity but nothing read it back, so the one path
where a wrong value produces commits attributed to the wrong author went
unverified. Three tests now cover it: the default identity, an overridden one, and
a run that commits nothing setting no identity at all.

Addresses review comment from @Jardo-51 on src/test/java/com/jardoapps/pao/git/FakeGitClient.java:53.
@Jardo-51

Jardo-51 commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Verification pass over all 13 unresolved review threads, at 334ce17 (working tree clean and equal to the PR head). All 13 check out against the code and are now resolved.

Thread Severity Fix Verdict
Sibling reactor dependency not moved CRITICAL c2208f4 Addressed
TIMEOUT_SECONDS can never fire HIGH 6ebfe57 Addressed
git commit -a commits too much MEDIUM b3d24ef Addressed
configureUser writes .git/config MEDIUM f3fb2e6 Addressed
Release version turned into a snapshot MEDIUM ed82c97 Addressed
GITHUB_REF_NAME on pull-request events MEDIUM 7eec1cf Addressed
POM encoding read/written as UTF-8 MEDIUM cfc5879 Addressed
RunResult.projectVersion was stale LOW 6a24e74 Addressed
Unconfigured vs. missing config file LOW fb21539 Addressed
Extra config columns silently dropped LOW 9cf3990 Addressed
IT summary double-counts a failed test LOW 3bd3d14 Addressed
Glob character class copied verbatim LOW b8e98ef Addressed
Git identity never asserted in tests LOW 334ce17 Addressed

Each fix was traced to the code rather than taken from the reply, and the four with the most substance were checked by mutation — reverting the fix in a scratch worktree and confirming the named tests fail: the sibling-dependency loop (2 failures in MultiModuleTest), the prolog encoding (3 failures in PomDocumentTest), the glob class body (3 failures in GlobMatcherTest), and the IT counting guard (summary drops to 6 passed, 1 failed when an assertion is forced to fail, instead of counting the test twice). The git timeout was reproduced directly against a child that holds stdout open.

Tests at 334ce17, run locally since the repository has no CI workflow: mvn -o test 100 tests, 0 failures; bash it/run-integration-tests.sh 7 passed, 0 failed.

Two General notes from the review summary have no thread and are still open:

  • No CI workflow. There is still no .github/ directory, so neither suite runs on a PR and the Actions snippet the README recommends is never exercised.
  • Integration coverage. test_commit_scope and test_leaves_no_git_config were added, but the IT reactor is still my-parent + core with no inter-module dependency, so the CRITICAL scenario is covered only by unit tests. ${revision} versions, a successful per-branch pin, and core-branch dependency stripping remain without integration coverage.

One thing to note rather than act on: 7eec1cf also added CI_MERGE_REQUEST_SOURCE_BRANCH_NAME for GitLab merged-results pipelines, which goes beyond the case that was reproduced. It is correct and symmetric with the GitHub handling, and the offer to pull it back out was never taken up, so it stays.

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