This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new f6fef7396 docs(skills): add releasing-struts, and configure the
release plugin in the pom (#1844)
f6fef7396 is described below
commit f6fef7396c9a8daba7f0e87d5ab6ebb3a23cf30b
Author: Lukasz Lenart <[email protected]>
AuthorDate: Fri Aug 14 12:39:54 2026 +0200
docs(skills): add releasing-struts, and configure the release plugin in the
pom (#1844)
* docs(skills): add releasing-struts, the end-to-end release runbook
The only end-to-end description of the release process was the cwiki
page "Building Struts 2 - Normal release", last revised in 2017. It is
still right about JIRA, the release plugin, Nexus and dist.apache.org,
and wrong about everything downstream: branches develop/master, tag
STRUTS_2_3_x, exporting the wiki to /docs, building the site with
Docker Jekyll and committing content/ into an svn production repo, and
publishing via people.apache.org. Following it today publishes to a
repository that no longer serves the site.
The four existing skills cover the paperwork -- Version Notes, the
[VOTE] mail, bulletins, report triage. Nothing covered the mechanics,
the ordering, or the last mile: the GA [ANN] mail, CVE registration,
the site update, and post-release follow-through.
releasing-struts is the spine: seven phases, each with a gate that
someone else can verify, cross-referencing the existing skills rather
than restating them. release-runbook.md holds the commands, with every
step marked as verified against the 7.3.0/6.11.0 run or as carried over
from the cwiki page and still unconfirmed, so the next release manager
knows which is which.
Three things the cwiki got wrong that were verified here:
- Releases are cut on a release/X.Y.Z-RC1 branch off the line, not on
main. The [maven-release-plugin] commits never reach main, which is
why the root pom still read 7.2.2-SNAPSHOT after 7.3.0 shipped -- and
why the pom is worthless as a source for the release number.
- The BOM needs no version sync; bom/pom.xml inherits through <parent>
and the struts-version.version property no longer exists.
- The GA [ANN] mail goes To user@ with Cc announce@ and
announcements@struts, not to dev@.
Co-authored-by: Claude Opus 5 <[email protected]>
* docs(skills): ship the stage and promote scripts with releasing-struts
Phases 3 and 5 were automated all along, in the release manager's local
toolbox at ~/Projects/Apache/minatour/bin. The runbook pointed at a
directory that only exists on one machine, which is how those steps came
to be marked unverified in the first place.
Ported both into scripts/, so the skill is self-contained:
stage-assemblies.sh closed staging repo -> dist/dev, renamed, re-hashed
promote-dist.sh dist/dev -> dist/release
Behaviour is unchanged. The deviations, listed in each script's header:
set -eu so a failed step cannot commit a partial set, a required $VERSION
so an unset variable cannot create an empty directory or move the whole
dev tree, tolerant md5/sha1 cleanup, a printed file list before the
commit, and an explicit -m so the svn move does not open $EDITOR.
.gitignore carries a blanket *.sh, which silently swallowed both files on
the first attempt. Negated for .claude/skills/**/scripts/*.sh only -- a
skill that ships a tool has to be able to ship it.
The toolbox's third script, update-struts2-draft-docs.sh, exports
Confluence into the retired svn production site. Deliberately not ported,
and called out as dead so nobody runs it.
Both phases are now marked verified. The remaining unverified steps are
the Nexus close/release clicks, the release:perform fallback, and whether
the omnibus JIRA ticket is still practice.
Co-authored-by: Claude Opus 5 <[email protected]>
* build(release): configure the release plugin in the pom, not on the
command line
The release ran on remembered flags: release:prepare needed
-DautoVersionSubmodules=true and release:perform
-DretryFailedDeploymentCount=10,
neither recorded anywhere but a 2017 wiki page. A flag that has to be
remembered is a flag that will eventually be forgotten, and forgetting
autoVersionSubmodules means answering a version prompt once per module.
autoVersionSubmodules moves onto maven-release-plugin. Verified against
the effective pom that it merges with, rather than replaces, the three
settings inherited from the ASF parent: useReleaseProfile=false,
goals=deploy, releaseProfiles=apache-release.
retryFailedDeploymentCount moves onto maven-deploy-plugin in
pluginManagement, which is the only place it can work: release:perform
forks a new Maven build and the fork does not inherit -D properties from
the outer invocation, so the flag on the command line was never reaching
the deploy.
The SCM tag stays interactive. Our tags are STRUTS_X_Y_Z and
tagNameFormat can only interpolate @{project.version}, which would give
STRUTS_7.3.0. Noted in the pom so the next reader does not retry it.
Both commands in the runbook lose their flags to match.
Only the 7.x line is changed here; support/struts-6-x-x needs the same
edit on its own branch.
Co-authored-by: Claude Opus 5 <[email protected]>
* docs(skills): address review on releasing-struts
Review comments:
- creating-security-bulletins is not "phase 7". A bulletin is written
when the report is triaged, often months before a release carries the
fix, and plenty are handled with no release in flight. Phase 7 is only
the reverse dependency: if this release carries a security fix, follow
that skill once phase 6 is done.
- Dropped every reference to the minatour toolbox, including the note
about its dead draft-docs script. The scripts live here now.
- Phase 1 checks the JDK before building. 7.x builds on 17 and 6.x on 8,
and whichever JDK is active in the shell is the one that produces the
release. On a mismatch the instruction is to stop and ask how to
switch: jenv, SDKMAN, asdf and a hand-set JAVA_HOME all differ, and
.java-version is gitignored, so there is nothing safe to infer from.
- Closing the Nexus staging repository is the release manager's action
in a web UI behind an ASF login. Now written as hand over, wait for
confirmation, then verify the group URL yourself.
Review findings:
- The site checklist named source/download.cgi, a six-line wrapper
around mirrors.cgi with no release content, and omitted
source/releases.md, which holds the release table and which both
August 2026 site PRs changed. Following it shipped an incomplete PR --
as PR #323 did earlier today.
- promote-dist.sh only rejected an empty VERSION while its header
claimed an unset variable could not move the whole dev directory.
svn resolves a "." path element rather than rejecting it (".." is
rejected, "." is not), so VERSION="." moved dist/dev/struts wholesale
in one irreversible commit. Both scripts now require a version-shaped
value.
- set -eu had silently dropped the original's unconditional cleanup, so
a failed run left an svn working copy with $VERSION already added; a
later run for a different version would have committed the stale tree
alongside the new one. Restored via trap, and struts-dev is now
guarded up front like $VERSION.
- The phase 3 gate listed five assemblies. There are six: min-lib was
missed by a [a-z]+ pattern that cannot match a hyphen. 24 files.
- The default tag would be struts2-project-X.Y.Z; the root artifactId is
struts2-project, not struts2-parent.
- The "is the staging repository closed?" diagnostic was unreachable in
the case it described: under set -e a 404 killed wget first. Wrapped.
- The rename glob had been widened from *2-assembly*.zip* to
*2-assembly*, which forced a compensating change to the .pom cleanup
and was not in the header's deviation list. Restored to the original
pair, with a comment noting the two lines are coupled.
- Documented invocation ran in the repository tree, creating ./$VERSION
and an svn working copy there. Now cd "$(mktemp -d)" first.
Guards exercised: empty, ".", "..", "abc" all rejected by both scripts;
the wget failure path prints its diagnostic and leaves no working copy.
Co-authored-by: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
---
.claude/skills/creating-release-vote-mail/SKILL.md | 3 +-
.claude/skills/creating-version-notes/SKILL.md | 2 +
.claude/skills/releasing-struts/SKILL.md | 148 +++++++++++
.claude/skills/releasing-struts/release-runbook.md | 292 +++++++++++++++++++++
.../releasing-struts/scripts/promote-dist.sh | 36 +++
.../releasing-struts/scripts/stage-assemblies.sh | 103 ++++++++
.gitignore | 2 +
pom.xml | 27 ++
8 files changed, 612 insertions(+), 1 deletion(-)
diff --git a/.claude/skills/creating-release-vote-mail/SKILL.md
b/.claude/skills/creating-release-vote-mail/SKILL.md
index e34815b88..4c14a96fd 100644
--- a/.claude/skills/creating-release-vote-mail/SKILL.md
+++ b/.claude/skills/creating-release-vote-mail/SKILL.md
@@ -14,7 +14,8 @@ boilerplate, around a plain-text rendering of the release's
Version Notes page.
**This is the step after `creating-version-notes`.** That skill produces the
page, the GitHub
release and the `[TEST]` announcement; this one consumes all three. If they do
not exist yet,
-you are in the wrong skill.
+you are in the wrong skill. `releasing-struts` holds the surrounding phases
and what happens
+once the vote passes.
[`vote-mail-template.md`](vote-mail-template.md) is the source of truth for
the artifact.
diff --git a/.claude/skills/creating-version-notes/SKILL.md
b/.claude/skills/creating-version-notes/SKILL.md
index e52ca187d..fd8a0fd3c 100644
--- a/.claude/skills/creating-version-notes/SKILL.md
+++ b/.claude/skills/creating-version-notes/SKILL.md
@@ -13,6 +13,8 @@ A Version Notes page answers one question for a user deciding
whether to upgrade
**One skill covers every maintenance line.** 6.x and 7.x pages share an
identical structure. The line changes the data (version, prior page, JIRA ids),
never the process.
+**This is phase 3 of a seven-phase release.** `releasing-struts` holds the
sequence, the gates and the mechanics either side of it; this skill owns the
paperwork.
+
## The Iron Rule
```
diff --git a/.claude/skills/releasing-struts/SKILL.md
b/.claude/skills/releasing-struts/SKILL.md
new file mode 100644
index 000000000..b94beedd5
--- /dev/null
+++ b/.claude/skills/releasing-struts/SKILL.md
@@ -0,0 +1,148 @@
+---
+name: releasing-struts
+description: Use when running or planning an Apache Struts release on any
maintenance line (6.x, 7.x) - cutting the tag, staging artifacts, opening the
vote, promoting, updating the site and announcing - or when asked what the next
step in a release is.
+---
+
+# Releasing Struts
+
+## Overview
+
+A release is seven phases with a gate between each. Most of the *writing* is
already covered by
+other skills; this one owns the **order, the gates, and the mechanics** — and
it is the only
+place that covers the last mile after the vote passes.
+
+**Core principle:** a phase is finished when its gate is verifiable by someone
other than you.
+"I ran the command" is not a gate; "the URL resolves" is.
+
+[`release-runbook.md`](release-runbook.md) holds the commands. This page holds
the sequence and
+the judgement.
+
+## The phases
+
+| # | Phase | Gate before moving on |
+|---|---|---|
+| 1 | Prepare | Branch green, versions decided, BOM in sync |
+| 2 | Cut | Tag pushed, artifacts in a **closed** Nexus staging repo |
+| 3 | Stage | Assemblies in `dist/dev`, Version Notes page live, `[TEST]` mail
sent |
+| 4 | Vote | 72 h elapsed, three binding `+1`, result mail sent |
+| 5 | Promote | Nexus repo released, `dist/dev` → `dist/release`, 24 h rsync
waited |
+| 6 | Publish | Site PR merged, GitHub release un-flagged, `[ANN]` mail
delivered |
+| 7 | Advisories | Bulletins public, CVE records filled, advisory mails
delivered |
+
+Phase 7 only exists when the release carries a security fix, and *publishing*
the advisory is
+**strictly after** phase 6 — see *Security work is a separate clock* below.
Writing the bulletin
+is not: it is usually drafted long before the release exists, and often on its
own timetable
+entirely.
+
+## Which skill owns which artifact
+
+Cross-references, not copies. Do not restate what these settle:
+
+- **`creating-version-notes`** — the Version Notes page, its Staging
Repository block, the
+ Migration Guide entry, the GitHub release notes, and the `[TEST]` mail. All
of phase 3's
+ paperwork.
+- **`creating-release-vote-mail`** — the `[VOTE]` mail. All of phase 4's
paperwork.
+- **`creating-security-bulletins`** — the S2-XXX page, what may be disclosed
and when,
+ publication, and the advisory mails.
+
+**That last one is not a phase of this process.** A bulletin gets written when
the report is
+triaged, which may be months before a release carries the fix, and plenty of
bulletins are
+handled with no release in flight at all. It is a skill in its own right,
invoked whenever it is
+needed. Phase 7 is the reverse direction: *if* this release carries a security
fix, then once
+phase 6 is done, go and follow that skill.
+
+This skill covers what none of them do: phases 1, 2, 5 and 6, and the ordering
that binds them.
+
+## Two lines, two releases
+
+`main` is the 7.x line; `support/struts-6-x-x` is 6.x. Both are protected and
both require their
+build to pass. A change that lands on both is **two releases**, each with its
own tag, vote,
+site entry and announcement — not one release mentioned twice.
+
+They can be cut in parallel and voted in parallel, and usually are. Keep the
version numbers
+independent: 6.11.0 and 7.3.0 shipped together and share nothing but a date.
+
+**Neither line branch is where the release is cut.** Both August 2026 releases
were built on a
+`release/X.Y.Z-RC1` branch off the line, so the `[maven-release-plugin]`
commits never reach
+`main`. A failed vote is then a deleted branch, not a revert.
+
+## The version number is chosen at release time
+
+The `-SNAPSHOT` in the pom is a placeholder, not a decision. Pick the number
from the semver
+impact of what actually landed since the last tag, and say so out loud before
cutting — the tag
+is the first irreversible act of the release.
+
+The pom cannot tell you: because releases are cut on a side branch, `main`
still read
+`7.2.2-SNAPSHOT` after 7.3.0 had shipped.
+
+## Security work is a separate clock
+
+**Nothing about an unpublished advisory goes into the release paperwork.** Not
the Version
+Notes, not the `[TEST]` mail, not the `[VOTE]`, not the commit messages, not
the site entry.
+The tickets are neutral; that is deliberate and it is what makes the embargo
survive a public
+release process.
+
+The advisory follows the release, and the ordering is not negotiable:
+
+```
+release GA → bulletin unrestricted → advisory mails → CVE pushed to MITRE
+```
+
+A bulletin published before the fixed artifact is downloadable tells attackers
what to look for
+and gives operators nothing to do about it.
+
+**A 6.x release containing only embargoed fixes is self-disclosing** — the
diff between the two
+tags is the vulnerability whatever the commit messages say. That is a reason
to bundle it with
+unrelated work, or to publish the bulletins with the release, not a reason to
pretend otherwise.
+
+## What the old cwiki page gets wrong
+
+[Building Struts 2 — Normal
release](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=27832970)
+was last revised in **2017** and is the page a release manager is most likely
to find. It is
+still right about JIRA, `release:prepare`/`release:perform`, Nexus and
`dist.apache.org`, and
+wrong about everything downstream:
+
+| It says | Reality |
+|---|---|
+| Branches `develop` / `master` | `main` and `support/struts-6-x-x` |
+| Tag `STRUTS_2_3_x` | `STRUTS_X_Y_Z` for the version being cut |
+| Export the wiki to `/docs` | The site no longer embeds exported Confluence
pages |
+| Build the site with Docker Jekyll, commit `content/` | The site builds from
a PR to `apache/struts-site` |
+| `svn co .../infra/websites/production/struts` | Gone; publishing is the
merge |
+| `people.apache.org`, `source/announce.md`, `downloads.html` | Dead host, and
the files are `announce-YYYY.md`, `releases.md` and `index.html` |
+
+Treat it as history. If you follow it, you will publish to a repository that
no longer serves
+the site.
+
+## Gates that are actually load-bearing
+
+- **A closed Nexus staging repo, not just a successful `release:perform`.**
Until it is closed
+ the URL in the Version Notes resolves to nothing and every tester is blocked.
+- **72 hours, and three binding `+1`.** PMC votes are the binding ones;
`private@` is on the
+ vote mail so binding voters see it.
+- **24 hours after the `dist` move, before announcing.** ASF mirroring
guidance. Announcing into
+ an unmirrored release sends everyone to a 404.
+- **The GitHub release stops being a prerelease at phase 6, not at phase 3.**
During the vote it
+ must still be flagged, or the vote is on an artifact the world already
treats as final.
+
+## Red Flags — STOP
+
+- Cutting a tag before the version number has been stated and agreed
+- A `[VOTE]` opened on a staging repo that is not closed, or on a link that
404s
+- Announcing before the 24-hour mirror wait
+- Any severity, CVE, S2-XXX or bulletin link in release paperwork
+- A bulletin unrestricted before the fixed release is downloadable
+- Following the 2017 cwiki page for anything after the Nexus step
+- One release "covering" both maintenance lines
+- Inferring the release version from the `-SNAPSHOT` in the pom
+
+## Common Mistakes
+
+| Mistake | Reality |
+|---|---|
+| "`release:perform` succeeded, so the artifacts are staged" | They are staged
and *open*. Close the repo or nobody can fetch them. |
+| "The vote passed, so it's released" | Nexus release, dist move and the
mirror wait all come after. |
+| "I'll announce now and fix the site after" | The announcement links the
site. Merge the site PR first. |
+| "The 6.x fix is the same change, so one announcement covers both" | Two
artifacts, two downloads, two sets of affected users. |
+| "The pom says 7.3.1-SNAPSHOT, so this is 7.3.1" | The placeholder is not a
decision. Semver impact decides. |
+| "The cwiki page is the official process" | It is the 2017 process. Where
they disagree, this skill is current. |
diff --git a/.claude/skills/releasing-struts/release-runbook.md
b/.claude/skills/releasing-struts/release-runbook.md
new file mode 100644
index 000000000..1985ed611
--- /dev/null
+++ b/.claude/skills/releasing-struts/release-runbook.md
@@ -0,0 +1,292 @@
+# Release Runbook
+
+The commands, in order. [`SKILL.md`](SKILL.md) holds the sequence, the gates
and the judgement;
+this file is what you type.
+
+**Provenance.** Everything marked ✔ was verified against the repository or a
completed release
+(7.3.0 / 6.11.0, August 2026). Everything marked **⚠ unverified** is carried
over from the 2017
+cwiki page and has *not* been confirmed against a current run — check it
before relying on it,
+and correct this file when you do.
+
+**The scripts.** Phases 3 and 5 ship with this skill, in [`scripts/`](scripts):
+
+| Script | Phase | What it does |
+|---|---|---|
+| [`stage-assemblies.sh`](scripts/stage-assemblies.sh) | 3 | Closed staging
repo → `dist/dev`, renamed and re-hashed |
+| [`promote-dist.sh`](scripts/promote-dist.sh) | 5 | `dist/dev` →
`dist/release` |
+
+Both take `$VERSION` from the environment, refuse to run without it, and
refuse a value that is
+not a version number — `svn` resolves a `.` path element rather than rejecting
it, so a stray
+`VERSION` would otherwise move the whole staging tree in one irreversible
commit.
+
+Run them from a scratch directory (`cd "$(mktemp -d)"`), never from a
repository checkout: the
+staging script creates `./$VERSION` and a temporary svn working copy in the
current directory.
+
+---
+
+## Phase 1 — Prepare
+
+✔ Two lines, two releases:
+
+| Line | Branch | Build check that must pass |
+|---|---|---|
+| 7.x | `main` | `Build and Test (JDK 17)` |
+| 6.x | `support/struts-6-x-x` | `Build and Test (8)` |
+
+Both branches are protected in `.asf.yaml` and must be green before you start.
+
+**Check the JDK before building anything.** The line dictates it — 7.x builds
on **JDK 17**, 6.x
+on **JDK 8** — and the whole release is produced by whichever JDK happens to
be active in the
+shell. Cutting 6.x on 17 produces artifacts that will not run for the users
that line exists for.
+
+```bash
+mvn -v # reports the JDK Maven is actually using, not just $JAVA_HOME
+```
+
+**If it is the wrong version, stop and ask the release manager how to
switch.** Local
+environments differ — jenv, SDKMAN, asdf, `JAVA_HOME` by hand, a Homebrew
symlink — and guessing
+at someone's toolchain is how you end up building against a JDK they did not
intend. Ask, do not
+infer. (`.java-version` is gitignored in this repo, so it is not a reliable
signal either.)
+
+```bash
+git checkout main && git pull --ff-only
+mvn clean install -DskipAssembly
+```
+
+Then:
+
+- Decide the version number from semver impact. Do not read it off the
`-SNAPSHOT`.
+- ✔ Confirm `struts-master` (currently `15`) and `struts-annotations` are
released versions, not
+ snapshots. The root pom's `<parent>` must not point at a snapshot.
+- ✔ The BOM needs no version sync. `bom/pom.xml` inherits the root version
through its
+ `<parent>` and declares members as `${project.version}`. The cwiki's
+ `struts-version.version` property no longer exists — ignore that step.
+- Review JIRA: every issue fixed since the last tag has a fix version; nothing
unresolved carries
+ this one.
+- ⚠ unverified: the cwiki's "omnibus ticket" step. The 7.3.0 and 6.11.0 runs
show no such ticket
+ — treat it as abandoned unless the PMC says otherwise.
+
+## Phase 2 — Cut
+
+✔ **Cut from a release branch, not from the line branch.** Both August 2026
releases were built
+on `release/X.Y.Z-RC1` branched off the line:
+
+```bash
+git checkout -b release/7.3.0-RC1 main # or off support/struts-6-x-x
for 6.x
+git push -u origin release/7.3.0-RC1
+```
+
+The two `[maven-release-plugin]` commits land there and **`main` is never
touched** — which is
+why the root pom still said `7.2.2-SNAPSHOT` after 7.3.0 shipped, and why the
pom is worthless
+as a source for the release number.
+
+✔ `maven-release-plugin` 3.3.1, driven interactively, on that branch:
+
+```bash
+mvn release:prepare
+```
+
+✔ No flags. `autoVersionSubmodules` is configured in the root pom, along with
the ASF parent's
+`useReleaseProfile=false`, `goals=deploy` and
`releaseProfiles=apache-release`. If you find
+yourself passing `-D` to the release plugin, the setting belongs in the pom
instead — a flag
+that has to be remembered is a flag that will be forgotten.
+
+✔ **At the SCM tag prompt, type `STRUTS_X_Y_Z`.** The plugin's default would be
+`struts2-project-X.Y.Z` (the root artifactId); every Struts tag in history is
the underscore
+form, and the GitHub release, the Version Notes and the site all assume it.
+
+This one cannot move into the pom: `tagNameFormat` interpolates
`@{project.version}` and has no
+string functions, so the best it could produce is `STRUTS_7.3.0`. The prompt
stays.
+
+Dry run first if you want one — add `-DdryRun=true`, then `mvn release:clean`
before the real
+run. On failure, re-run the same command: `-Dresume` defaults to true and it
picks up where it
+stopped.
+
+✔ The result is two commits on the release branch,
+`[maven-release-plugin] prepare release STRUTS_X_Y_Z` and
+`[maven-release-plugin] prepare for next development iteration`, plus the tag.
+
+```bash
+mvn release:perform
+```
+
+✔ `retryFailedDeploymentCount=10` is configured on `maven-deploy-plugin` in
the root pom, not
+passed here. It has to be in the pom to work at all: `release:perform` forks a
fresh Maven
+build, and that fork does not inherit `-D` properties from the outer
invocation — the flag the
+cwiki tells you to pass was doing nothing.
+
+⚠ unverified: the fallback for re-running `perform` elsewhere —
+`git checkout STRUTS_X_Y_Z && mvn javadoc:javadoc deploy -DperformRelease=true
-Papache-release`.
+
+**Then the staging repository has to be closed — and that is the release
manager's action, not
+yours.** It happens in the Nexus web UI at <https://repository.apache.org/>
(Staging Repositories
+→ select → Close), behind an ASF login. Say so, hand over, and **wait for
confirmation before
+continuing** — phase 3 fetches from the staging *group* URL and gets nothing
while the repo is
+open.
+
+⚠ unverified in detail, but the gate is checkable and worth checking yourself
once you are told
+it is done: the artifacts must resolve under
+
+```
+https://repository.apache.org/content/groups/staging/org/apache/struts/struts2-core/$VERSION/
+```
+
+The staging repo is keyed by user *and* public IP. If your IP changed
mid-release you will have
+two; drop the stale one, checking the dates.
+
+## Phase 3 — Stage
+
+✔ [`scripts/stage-assemblies.sh`](scripts/stage-assemblies.sh) does this. It
runs on your own
+machine — the cwiki's "log in to `people.apache.org`" step is dead, that host
is gone.
+
+```bash
+VERSION=7.3.0 .claude/skills/releasing-struts/scripts/stage-assemblies.sh
+```
+
+It fetches `zip`, `md5`, `sha1` and `asc` from the **closed** staging repo,
strips the
+`2-assembly` infix, drops the `.pom*` files and the legacy `md5`/`sha1`
hashes, generates
+`.sha256` and `.sha512` locally with `shasum`, prints what it is about to
publish, then
+`svn add`s the directory to `dist/dev/struts` and commits. It needs your ASF
svn credentials.
+
+✔ Gate, verified against `dist/release/struts/7.3.0/`:
+
+```
+https://dist.apache.org/repos/dist/dev/struts/$VERSION/
+```
+
+holds **six** assemblies — `struts-$VERSION-all.zip`, `-apps.zip`,
`-docs.zip`, `-lib.zip`,
+`-min-lib.zip` and `-src.zip` — each with `.asc`, `.sha256` and `.sha512`
beside it: 24 files.
+No `.md5`, no `.sha1`, no `.pom`. `KEYS` lives one level up, in
`dist/release/struts/`.
+
+Count them. `set -eu` stops the script on a step that *fails*, not on a crawl
that quietly
+returns a subset, so a short upload reaches `dist/dev` looking healthy.
+
+**The staging repo must be closed before you run this** — the script pulls
from the staging
+*group* URL, and an open repo serves nothing there.
+
+Everything else in this phase belongs to **`creating-version-notes`**: the
Version Notes page,
+its Staging Repository block, the Migration Guide entry, the GitHub release
(created as a
+**prerelease**), and the `[TEST]` mail to `dev@` and `user@`.
+
+## Phase 4 — Vote
+
+**`creating-release-vote-mail`** owns the mail. The mechanics around it:
+
+- 72 hours minimum, three binding `+1` (PMC members).
+- ✔ `To: [email protected]`, `Bcc: [email protected]`. Never
`user@`.
+- Close with a result mail on the same thread.
+
+## Phase 5 — Promote
+
+✔ [`scripts/promote-dist.sh`](scripts/promote-dist.sh) does this — one
server-side `svn mv`:
+
+```bash
+VERSION=7.3.0 .claude/skills/releasing-struts/scripts/promote-dist.sh
+# svn mv https://dist.apache.org/repos/dist/dev/struts/$VERSION/ \
+# https://dist.apache.org/repos/dist/release/struts/ -m "Release Struts
$VERSION"
+```
+
+Then **release** the staging repository in Nexus, which replicates to Maven
Central.
+
+✔ On pruning old releases: the cwiki says to keep only the latest. Current
practice does not —
+`dist/release/struts/` held 6.8.0, 6.9.0, 6.10.0, 6.11.0, 7.1.1, 7.2.1, 7.3.0
and `KEYS` in
+August 2026. Everything removed stays available at
+<https://archive.apache.org/dist/struts/>. Decide deliberately; do not prune
on autopilot.
+
+**Then wait 24 hours** for mirrors before anything in phase 6.
+
+## Phase 6 — Publish
+
+### The site — a PR to `apache/struts-site`
+
+✔ Verified against PR #322 (the 7.3.0 / 6.11.0 GA announcement) and #323.
+
+`_config.yml` — all of these move together:
+
+```yaml
+current_version: 7.3.0
+current_version_short: 730
+prev_version: 6.11.0
+prev_version_short: 6110
+release_date: 1 August 2026
+prev_release_date: 1 August 2026
+release_date_short: 20260801
+prev_release_date_short: 20260801-6110
+```
+
+`release_date` is the **tag** date, not the announcement date — 7.2.1 was
tagged 15 June and
+announced 30 June, and the site says 15 June. The `*_date_short` values are
the anchors in
+`announce-YYYY.md`; when two releases share a tag date, disambiguate the second
+(`20260801-6110`) so the two home-page boxes link to their own entries.
+
+Then:
+
+- `source/announce-YYYY.md` — a new `####` entry at the top, newest first,
with its `{#aYYYYMMDD}`
+ anchor.
+- ✔ `source/releases.md` — the release table (Release / Release Date /
Vulnerability / Version
+ Notes). **Easy to miss and both August 2026 PRs changed it**; a site PR
without it is
+ incomplete.
+- `source/index.html` — the GA boxes read from `_config.yml`; the security
boxes are hand-edited.
+- `source/dtds/` — only if a new DTD shipped.
+
+✔ **Not** `source/download.cgi` — that is a six-line wrapper around
`mirrors.cgi` with no release
+content in it. `source/download.md` interpolates versions from `_config.yml`
and its *Prior
+releases* section is a static pointer to `archive.apache.org`, so neither
needs a per-release
+edit.
+
+Publishing is the merge. There is no separate deploy step and no svn.
+
+### GitHub release
+
+✔ Un-flag the prerelease. Title `Struts X.Y.Z`, tag `STRUTS_X_Y_Z`.
+
+### The `[ANN]` mail
+
+✔ Recipients, from the 7.3.0 and 6.11.0 announcements:
+
+```
+To: [email protected]
+Cc: [email protected], [email protected]
+```
+
+`dev@` is not on it — the list already saw the `[TEST]` mail and the vote.
+
+✔ **Plain text only, and sent from the `@apache.org` identity.**
`[email protected]` rejects
+any message carrying a `text/html` part —
+
+```
+ezmlm-reject: fatal: Sorry, a message part has an unacceptable MIME
Content-Type: 'text/html' (#5.2.3)
+```
+
+— and `[email protected]` answers *"Must be sent from an
@apache.org address."*
+A draft made with the Gmail tool is an HTML draft whatever you pass it; see
*The mail must be
+text/plain* in `creating-release-vote-mail` for the full contract. One list
accepting the mail
+is not evidence the format was right.
+
+Body: the GA boilerplate ("pleased to announce … General Availability …
highest quality grade"),
+the Version Notes link, the Migration Guide link for a major line, the minimum
JDK/spec
+requirements for that line, and the download page.
+
+## Phase 7 — Advisories
+
+Only when the release carries a security fix, and the *publication* only after
phase 6. The
+bulletin itself was almost certainly written when the report was triaged, long
before this
+release existed.
+
+**`creating-security-bulletins`** owns all of it: unrestricting the bulletin,
the CVE record on
+<https://cveprocess.apache.org>, and the advisory mails from that record's
*OSS/ASF Emails* tab.
+Follow that skill from here; it is not a step in this runbook.
+
+The order that matters here: the CVE record goes `RESERVED → DRAFT → READY`,
and **READY is the
+last state a PMC sets**. ASF Security submits it to the CVE Program and sets
`PUBLIC`, so
+`cve.org` links 404 until they do. That is expected, and it is not a reason to
delay the
+bulletin or the mails.
+
+## Post-release
+
+- Add the site announcement entry for any advisory (`announce-YYYY.md`), same
form as the GA one.
+- Check NVD once the CVE is public — affected ranges have been wrong before,
and the fix is an
+ email to `[email protected]` citing the CVE record.
+- Answer any coordinator (JPCERT/CC and similar) in their existing thread once
the bulletin is
+ live; they hold their advisory until you confirm.
+- Update the Version Notes page if the vote forced a re-cut.
diff --git a/.claude/skills/releasing-struts/scripts/promote-dist.sh
b/.claude/skills/releasing-struts/scripts/promote-dist.sh
new file mode 100755
index 000000000..89e408779
--- /dev/null
+++ b/.claude/skills/releasing-struts/scripts/promote-dist.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+#
+# Phase 5 - promote a release that passed its vote, moving the assemblies from
+# dist/dev to dist/release. This is the point at which the artifacts start
+# replicating to the mirrors.
+#
+# Usage: VERSION=7.3.0 ./promote-dist.sh
+#
+# Run it only after the vote has passed. Wait 24 hours after this before
+# announcing anything - the announcement links a download page that the mirrors
+# have to have caught up with first.
+
+set -eu
+
+if [ -z "${VERSION:-}" ]; then
+ echo "VERSION is not set. Usage: VERSION=7.3.0 $0" >&2
+ exit 1
+fi
+
+# Not cosmetic. svn resolves a "." path element instead of rejecting it, so
+# VERSION="." would move the whole of dist/dev/struts into dist/release in one
+# irreversible server-side commit. ".." is rejected by svn; "." is not.
+case "$VERSION" in
+ [0-9]*.[0-9]*.[0-9]*) ;;
+ *)
+ echo "VERSION must look like 7.3.0 (got '$VERSION')" >&2
+ exit 1
+ ;;
+esac
+
+svn mv "https://dist.apache.org/repos/dist/dev/struts/$VERSION/" \
+ "https://dist.apache.org/repos/dist/release/struts/" \
+ -m "Release Struts $VERSION"
+
+echo "Done - verify
https://dist.apache.org/repos/dist/release/struts/$VERSION/"
+echo "Now release the staging repository in Nexus, then wait 24 hours before
announcing."
diff --git a/.claude/skills/releasing-struts/scripts/stage-assemblies.sh
b/.claude/skills/releasing-struts/scripts/stage-assemblies.sh
new file mode 100755
index 000000000..967c74682
--- /dev/null
+++ b/.claude/skills/releasing-struts/scripts/stage-assemblies.sh
@@ -0,0 +1,103 @@
+#!/bin/sh
+#
+# Phase 3 - move the release assemblies from the closed Nexus staging
repository
+# into https://dist.apache.org/repos/dist/dev/struts/$VERSION so they can be
tested
+# and voted on.
+#
+# Usage: cd "$(mktemp -d)" && VERSION=7.3.0 /path/to/stage-assemblies.sh
+#
+# Run it from a scratch directory, not from a repository checkout: it creates
+# ./$VERSION and a temporary svn working copy in the current directory.
+#
+# Requires: the staging repository must already be CLOSED in Nexus (an open
repo
+# serves nothing under the staging *group* URL this fetches from), and your ASF
+# svn credentials for dist.apache.org.
+
+set -eu
+
+if [ -z "${VERSION:-}" ]; then
+ echo "VERSION is not set. Usage: VERSION=7.3.0 $0" >&2
+ exit 1
+fi
+
+# Not cosmetic: a VERSION of "." resolves server-side to the parent directory,
+# which would publish the whole staging tree.
+case "$VERSION" in
+ [0-9]*.[0-9]*.[0-9]*) ;;
+ *)
+ echo "VERSION must look like 7.3.0 (got '$VERSION')" >&2
+ exit 1
+ ;;
+esac
+
+STAGING_URL="https://repository.apache.org/content/groups/staging/org/apache/struts/struts2-assembly/$VERSION"
+DIST_DEV_URL="https://dist.apache.org/repos/dist/dev/struts/"
+
+if [ -e "$VERSION" ]; then
+ echo "Directory $VERSION already exists here - remove it or run
elsewhere." >&2
+ exit 1
+fi
+if [ -e struts-dev ]; then
+ echo "Directory struts-dev already exists here - remove it or run
elsewhere." >&2
+ exit 1
+fi
+
+# Unconditional, as in the original: a half-built working copy left behind can
be
+# picked up and committed by a later run for a different version.
+cleanup() {
+ rm -rf "$START_DIR/struts-dev"
+}
+START_DIR=$(pwd)
+trap cleanup EXIT
+
+echo "Creating working dir $VERSION"
+mkdir "$VERSION"
+cd "$VERSION"
+
+echo "Getting distro $VERSION from the staging repository"
+if ! wget -erobots=off -nv -l 1 --accept=zip,md5,sha1,asc -r
--no-check-certificate -nd -nH "$STAGING_URL"; then
+ echo "Download failed. Is the staging repository closed in Nexus?" >&2
+ exit 1
+fi
+
+if ! ls ./*.zip >/dev/null 2>&1; then
+ echo "No assemblies downloaded. Is the staging repository closed in
Nexus?" >&2
+ exit 1
+fi
+
+# struts2-assembly-7.3.0-all.zip -> struts-7.3.0-all.zip, and the same for the
+# .asc/.md5/.sha1 beside each zip. The .pom files keep their name and are
removed
+# below - narrowing this glob without widening that one republishes them.
+echo "Renaming files"
+for f in *2-assembly*.zip*; do
+ [ -e "$f" ] || continue
+ mv "$f" "$(echo "$f" | sed s/2-assembly//g)"
+done
+
+echo "Removing unneeded files"
+rm -f struts2-assembly-*.pom*
+rm -f ./*.md5 ./*.sha1
+
+# The ASF publishes sha256/sha512; Nexus only carries the legacy hashes.
+echo "Generating SHA signatures"
+for f in *.zip; do
+ [ -f "$f" ] || continue
+ shasum -a 256 "$f" > "$f.sha256"
+ shasum -a 512 "$f" > "$f.sha512"
+done
+
+echo "Staging the following files:"
+ls -1
+
+cd "$START_DIR"
+
+echo "Publishing artifacts for test"
+svn --no-auth-cache co --depth empty "$DIST_DEV_URL" struts-dev
+mv "$VERSION" struts-dev/
+cd struts-dev
+svn add --force ./
+svn --no-auth-cache commit -m "Updates test release $VERSION"
+
+cd "$START_DIR"
+
+echo "Done - verify https://dist.apache.org/repos/dist/dev/struts/$VERSION/"
diff --git a/.gitignore b/.gitignore
index 85c62f2c4..621f263b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,8 @@ Servers/
# Scripts
*.sh
+# ... except the ones that are part of a skill and have to travel with it
+!.claude/skills/**/scripts/*.sh
# jenv
.java-version
diff --git a/pom.xml b/pom.xml
index 03a86c1b1..05de4760a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -450,6 +450,19 @@
<artifactId>maven-site-plugin</artifactId>
<version>3.22.0</version>
</plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-deploy-plugin</artifactId>
+ <!--
+ Releasing pushes a large number of artifacts to Nexus
and a single dropped
+ connection fails the whole deploy. Configured here
rather than passed on
+ the command line: release:perform forks a new Maven
build, which does not
+ inherit -D properties from the outer invocation.
+ -->
+ <configuration>
+
<retryFailedDeploymentCount>10</retryFailedDeploymentCount>
+ </configuration>
+ </plugin>
</plugins>
</pluginManagement>
@@ -458,6 +471,20 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-release-plugin</artifactId>
<version>3.3.1</version>
+ <configuration>
+ <!--
+ Every module shares the project version, so the release
plugin should
+ version them automatically instead of prompting once per
module.
+
+ The SCM tag is still entered at the prompt: our tags are
STRUTS_X_Y_Z and
+ tagNameFormat can only interpolate @{project.version},
which would give
+ STRUTS_7.3.0.
+
+ useReleaseProfile, goals and releaseProfiles are
inherited from the ASF
+ parent pom and are intentionally not repeated here.
+ -->
+ <autoVersionSubmodules>true</autoVersionSubmodules>
+ </configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>