Skip to content

Add a release-merge action that merges the release into the base and develop, then publishes it - #10

Open
glaubersilva wants to merge 12 commits into
mainfrom
soft-5188-release-merge-action
Open

glaubersilva wants to merge 12 commits into
mainfrom
soft-5188-release-merge-action

Conversation

@glaubersilva

@glaubersilva glaubersilva commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Resolves SOFT-5188

Adds release-merge, a composite action plus a caller workflow to copy: the part of a deploy that is the same for every plugin repository with a base branch and a develop branch. The base branch is main, or master where that is the repository's name for it (Give); it is the base-branch input, and "base branch" below means that branch. It follows the shape #3 and #7 set: the logic is a CommonJS module run by actions/github-script, inputs reach it through env, and it is unit tested with node:test against the shared harness.

What it does

In order, stopping at the first failure:

  1. Checks the inputs, and the version against the repository's tags.
  2. Fetches the release branch, the base branch and develop from origin, with the full history when the checkout is shallow.
  3. Merges the release branch into the base branch as a merge commit and pushes it.
  4. Merges the base branch into develop as a merge commit and pushes it.
  5. Publishes the GitHub release: tag and title are the version, target is the base branch, notes are the ones GitHub generates. publish: false skips it.

The checkout is left on the base branch at the merged commit, so the caller builds and deploys from there. Two callers use it: the deploy workflows of the two test plugins, in place of the stub in stellarwp/release-dashboard-test-actions, and GiveWP's release-dashboard-deploy.yml in impress-org/givewp#8529, which runs the action with base-branch: master and then GiveWP's build, WordPress.org deploy and Slack steps.

Choices worth a look

  • The caller checks out; the action fetches. Same split as release-prep. The checkout can be on any branch and at any depth: the action fetches the three branches it needs, --unshallow first when the checkout is shallow.
  • The token never reaches .git/config. It goes to git through GIT_CONFIG_COUNT / GIT_CONFIG_KEY_0 / GIT_CONFIG_VALUE_0 in the environment of the fetch and the two pushes only (the http.<server>/.extraheader header actions/checkout would store), and the header is registered as a secret. The caller keeps persist-credentials: false, which matters when the same job goes on to run third-party actions. Nothing with the token is on a command line, so nothing with it reaches the log.
  • The base branch is merged into develop, not the release branch. develop then descends from the base, and consecutive releases bring one version bump each instead of divergent copies of the same lines. Both merges are --no-ff, so a release is one merge commit in the history.
  • A conflict prints MERGE_CONFLICT on a line of its own. The merge is abandoned (git merge --abort), nothing is pushed for that branch, the conflict output names it and the step fails. The release dashboard classifies the run from that line, anchored at the start of a timestamped log line. The marker is printed by the script, not written in the workflow, so the step's echoed inputs never carry it and it appears only when a merge did conflict. The two lines git prints are in the log as well, which is the dashboard's fallback.
  • Versions. Three or four numeric parts, the rule the dashboard applies and release-prep applies under the stellarwp changelog versioning, now in .github/lib/versions.js and imported by both actions. A pre-release and a leading v are refused: the version becomes the tag and the title of a published release. The version also has to be the one the release branch holds: the action reads every file the branch's .puprc lists under paths.versions, with the regex pup uses for each, and refuses when any holds a different version, when one holds none, or when the branch has no .puprc, naming the file. The tag comes from version, so a value other than what release-prep wrote would publish the code under the wrong version. A tag that exists without a published release is refused before anything is merged. A tag with a published release is a retry only when it contains the release branch; when it does not, the version went out from another commit and the branch was never bumped past it, and the action refuses with the branches untouched.
  • Every step is safe to rerun. A branch that already holds the other merges as "Already up to date" and pushes nothing; the publish step reads the release again rather than trusting the merge step's outputs, and skips one that exists. A conflict resolved by hand and pushed is found as an already-merged branch on the next run.
  • Publish through the API. generateReleaseNotes then createRelease, through github-script's Octokit. A release created with the default token starts no release workflow, which is wanted: the run that merged the release deploys it, so a repository with an on: release deploy does not deploy twice. GitHub renders the https://github.com/…/pull/N links in the generated notes as #N on the release page, so the stub's rewrite of them is gone.
  • The template runs one deploy at a time per repository. Two releases prepared with the same version and deployed together would both pass the preflight, since neither tag exists yet, and merge side by side. A composite action has no concurrency, so the caller template uses one group for the whole workflow, not one per release: the second deploy waits, finds the tag published from another branch, and is refused. The README has the reasoning.
  • No force_fail input. The stub had one for drills; here a failure is tested with a real cause.
  • Branch checks. Each name has to pass git check-ref-format, base and develop have to differ, and the release branch cannot be either. publish is checked in the merge step as well, so a value that is neither true nor false fails before anything is merged.

Shared code

  • .github/lib/github.js: readRepository (owner and name from GITHUB_REPOSITORY), isNotFound and tagExists, which treats a 404 as "no tag" and any other API error as a failure. Both actions use them; release-prep's runCommit did the repository read inline before.
  • .github/lib/exec.js: runOrFail, which release-prep had as a private function; release-merge runs git through it.
  • .github/lib/puprc.js: readVersionFiles (the paths.versions entries of a .puprc) and versionIn (the version a file holds, by the entry's regex). release-merge reads the branch's version with them; release-prep keeps its own .puprc reader for now, since it also reads checks.tbd.dirs.
  • STELLARWP_VERSION_PATTERN moves from release-prep.js to .github/lib/versions.js. release-prep keeps its export.
  • recordedRealExec, the wrapper around the real @actions/exec the release-prep tests defined for themselves, moves to tests/support/actions.js: the merge tests run git for real.

Tests

node --test: 257 pass (213 before, 44 added). The merge tests build a bare repository on disk with main, a develop ahead of it and a release/… branch, clone it as the action's checkout and run the real git through @actions/exec. They assert both branches' contents on the remote afterwards, the merge commits and their author, the checkout left on the base, the token present only in the environment of the fetch and the pushes and absent from .git/config, a no-op retry, a conflict on each of the two merges (marker at the start of a log line, nothing pushed for that branch, checkout clean), a published tag that does and does not contain the branch, a version the branch does not hold (in the first file, in the second file, a file the regex does not match, a regex JavaScript rejects, no .puprc), a wrong publish value refused before any git call, and a shallow clone over file://. The preflight and the publish run against an Octokit whose network is recorded.

Five mutations were checked against the suite: the marker in lower case, the merges without --no-ff, a conflict that neither aborts nor fails, the tag ancestry check removed, and the branch-version check removed. Each one is caught.

actionlint 1.7.12 is clean on the template and on test.yml. It still flags queue: max in release-tags.yml, as it did on #7; #6 adds the actionlint.yaml that accepts it, so this branch leaves that alone.

Verified end to end

On the two private test plugins in this org. Their deploy callers point at this branch: release.yml of release-test-plugin-a (the dashboard's default deploy workflow name) and release-dashboard-deploy.yml of release-test-plugin-b (the override name). Every run was dispatched with the release branch, version and production, the inputs the NX1 release dashboard sends. The callers took the branch as release_name and put release/ in front of it until stellarwp/nexcess-one#149; they now take branch whole, as the last row shows, and every checkout was an actions/checkout@v6 default, so shallow. The first five runs were at 1027c4a, the next five at 8bce125, which added the tag ancestry check, and the last two at 4779f4c, the head of this branch, which added the branch-version check.

Case Run Result
Release 2.1.36 plugin-a#41096923230 main gets Merge release/M26.smoke1 into main, develop gets Merge main back into develop (release/M26.smoke1), both by github-actions[bot]; release 2.1.36 published on main. The only AUTHORIZATION line in the log is actions/checkout's own, masked.
Same release again plugin-a#41097306245 Succeeds: both merges "Already up to date", both pushes "Everything up-to-date", the release reported as already published and skipped. Nothing changed.
Version 2.1.37-beta.1 plugin-a#41097488041 Fails in the first step, "not a version this action can release"; no fetch, merge or push.
Release 1.1.10 with develop changed on a line the release changes plugin-b#41097608948 main gets the release and is pushed; the merge into develop conflicts, is aborted, and the job fails after MERGE_CONFLICT: merging main into develop conflicted… on a line of its own. develop unchanged, no release. The release dashboard's detector, run on the raw job log, reads it as a merge conflict from the marker.
Same release, after resolving develop by hand and pushing plugin-b#41097775557 Succeeds: both merges found done, release 1.1.10 published.
Release M26.test40 with both plugins, driven from the NX1 release dashboard: prepare, review on the PR the dashboard opens (plugin-a#49, plugin-b#13), RC build, deploy plugin-a#41106676082, plugin-b#41106675436 Both merged into main and develop (a, b); the dashboard's PRs show as merged by those commits. The branches had been cut right after 2.1.36 and 1.1.10 were published, so they still carried those versions: the publish was skipped and the changes landed under versions already out. That is the case the tag ancestry check now refuses, next row.
release/M26.test40 at 2.1.36 again, a version published before the branch was cut plugin-a#41109312025 Fails before merging: "The tag 2.1.36 already exists and does not contain release/M26.test40 … the branch was never bumped past it. Give the release a new version and prepare the branch again." Nothing pushed.
release/M26.conflict at 1.1.10 again, the branch 1.1.10 was published from plugin-b#41109318189 Goes on as a retry: "already published … from release/M26.conflict", both merges "nothing to merge", publish skipped.
Two releases at 2.1.37 dispatched in the same second, release/M26.race1 and release/M26.race2, under the template's concurrency group plugin-a#41113611177, plugin-a#41113611329 race2 ran at once: merged, 2.1.37 published. race1 was held as pending until it finished, then refused: "The tag 2.1.37 already exists and does not contain release/M26.race1". main holds race2 only. Which run goes first is up to GitHub, not the dispatch order.
release/M26.test40, which holds 2.1.36, dispatched as 2.1.37 plugin-a#41124404376 Fails before merging: "release/M26.test40 holds 2.1.36 in release-test-plugin-a.php, not 2.1.37. The version to release has to be the one the branch was prepared with". Nothing pushed.
release/M26.conflict at 1.1.10 again, after the branch-version check plugin-b#41124410341 Every version file holds 1.1.10; goes on as a retry, publish skipped.
release/M26.conflict at 1.1.10 again, dispatched as -f branch=release/M26.conflict on the caller that takes branch plugin-b#41127395270 The action receives branch: release/M26.conflict as it is and goes on as a retry.
Release M26.test41 with both plugins, driven from the dashboard with the branch contract of stellarwp/nexcess-one#149, at new versions 2.1.38 and 1.1.11 plugin-a#41129712043, plugin-b#41129712862 Both runs receive branch: release/M26.test41 from the dashboard, merge into main and develop, and publish 2.1.38 and 1.1.11 on main with generated notes: the first releases created end to end by the dashboard through this action.

Trying it yourself

  1. In a plugin repository with main and develop, copy templates/workflows/release-merge.yml to .github/workflows/ on the default branch, with uses: pointing at this branch's commit.
  2. Cut release/x.y.z from main, change a file on it and push.
  3. gh workflow run release-merge.yml -f branch=release/x.y.z -f version=x.y.z
  4. main gets Merge release/x.y.z into main, develop gets Merge main back into develop (release/x.y.z), both by github-actions[bot], and a release x.y.z exists with generated notes.
  5. Run step 3 again: the job succeeds, both merges report "Already up to date", the release is skipped.
  6. Change the same line on develop and on a new release branch, then run step 3 for that one: the job fails on the develop merge with a MERGE_CONFLICT: line, main has the release, develop is unchanged.

Summary by CodeRabbit

  • New Features
    • Added a release workflow that merges a release branch into the base branch and then into the development branch, with an optional GitHub release.
    • Release versions and existing tags are checked before merging. Conflicts are reported, and completed merges can be safely retried.
    • Added a manually triggered workflow template for running the release process.
  • Documentation
    • Added setup guidance covering inputs, publishing options, conflict handling, and workflow configuration.

release-prep's three-or-four-parts pattern moves to .github/lib/versions.js, where the other version
rules live, so the release-merge action applies the same rule from the same place. The wrapper around
the real @actions/exec moves from the release-prep test into tests/support, for the tests that run
git for real.
Merges the release branch into the base branch and the base back into develop, then publishes the
GitHub release for the version. Every step is safe to rerun: a branch that already holds the other
merges as a no-op and an existing release is skipped. A conflict abandons the merge, prints a
MERGE_CONFLICT line the release dashboard reads, and fails with the branch named.

The token reaches git through the environment of the fetch and the pushes only, never the
checkout's config, so the caller can keep persist-credentials off and run third-party actions after
it. The tests run the merges with real git against a bare repository on disk.
@linear

linear Bot commented Oct 30, 2026

Copy link
Copy Markdown

SOFT-5188

@coderabbitai

coderabbitai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: f5f1bb46-3613-4c27-b95d-0e972554e406
Commits

Reviewing files that changed from the base of the PR and between 1027c4a and b066fe6.

Files selected for processing: 10
  • .github/actions/release-merge/README.md
  • .github/actions/release-merge/action.yml
  • .github/actions/release-merge/release-merge.js
  • .github/actions/release-prep/release-prep.js
  • .github/lib/exec.js
  • .github/lib/github.js
  • .github/lib/puprc.js
  • .github/lib/versions.js
  • templates/workflows/release-merge.yml
  • tests/actions/release-merge/release-merge.test.js

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Walkthrough

This change adds a composite GitHub Action that validates release inputs, merges the release branch into the base branch and the base branch into develop, and optionally publishes a GitHub release. It also adds a manual workflow template, shared helpers, documentation, and tests.

Changes: Release merge

Layer / File(s) Summary
Action contract and shared helpers
.github/lib/versions.js, .github/lib/exec.js, .github/lib/github.js, .github/lib/puprc.js, .github/actions/release-merge/action.yml, .github/actions/release-merge/release-merge.js, .github/actions/release-prep/release-prep.js, tests/support/actions.js, tests/actions/release-prep/release-prep.test.js
Adds a shared three- or four-part numeric version pattern and helpers for command execution, repository and tag lookups, and .puprc version data. The action declares its inputs and outputs and validates version, branch, publish, and release-state conditions. release-prep uses the shared version pattern and helpers; its test uses shared recorded-execution support.
Branch merge and conflict handling
.github/actions/release-merge/release-merge.js, .github/actions/release-merge/action.yml, tests/actions/release-merge/release-merge.test.js
Fetches and validates the release, base, and develop branches. Merges and pushes release into base, then base into develop. Reports conflicts and sets merge outputs. Tests cover validation, retries, merge behavior, conflicts, and failure cases.
GitHub release publishing
.github/actions/release-merge/release-merge.js, tests/actions/release-merge/release-merge.test.js
Skips publishing when disabled or when the release already exists. Otherwise, generates release notes and creates a release against the base branch.
Manual workflow and caller documentation
templates/workflows/release-merge.yml, .github/actions/release-merge/README.md, README.md
Adds a manually dispatched workflow that invokes the action with the release branch and version. Documents the action’s inputs, outputs, behavior, and caller requirements. The Phase 2 checklist marks the action complete and points to the workflow template.

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Workflow as release-merge workflow
  participant Action as composite action
  participant Merge as runMerge
  participant Git as Git
  participant Publish as runPublish
  participant GitHub as GitHub API
  Workflow->>Action: pass release branch and version
  Action->>Merge: pass inputs and token
  Merge->>GitHub: check tag and release
  Merge->>Git: fetch branches and push merges
  Action->>Publish: pass validated version and publish setting
  Publish->>GitHub: check or create release
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: d4mation, dpanta94

Merge Risk: Minimal · up to b066f

The release-merge action and caller template are ready to merge. No concrete blocking risk was established. Publishing a release after a long-delayed retry targets the base branch name, which is a narrow edge case worth keeping in mind.

Pre-merge checks: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title names the main change: a new release-merge action that merges the release into the base and develop branches and then publishes the release. The raw summary and objectives confirm this is th…
Docstring Coverage Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title names the main change: a new release-merge action that merges the release into the base and develop branches and then publishes the release. The raw summary and objectives confirm this is the primary change. The title is 95 characters, which is long but still within a readable single sentence, and it uses no vague terms or file lists.

Full details: Docstring Coverage

Explanation

Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 22 functions across 9 files. (3 skipped: 3 unsupported.)

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@glaubersilva glaubersilva self-assigned this Oct 30, 2026
A tag with a published release was read as a retry and the merges went on with the publish
skipped. That is right when the release came from this branch, and wrong when the version went out
earlier from another commit and the branch was never bumped past it: the new changes would land under
a version that is already out. The tag is now fetched and the action goes on only when it contains
the branch; otherwise it refuses before merging, naming the fix.
Two releases prepared with the same version and deployed at the same time would merge side by side,
the second landing its changes under a version the first had just published. One concurrency group
for the whole repository makes the second wait, and the action then refuses it on the published tag.
The README says why the caller has to do this.
readRepository, isNotFound and tagExists move to .github/lib/github.js and runOrFail to
.github/lib/exec.js, so release-prep and release-merge read GITHUB_REPOSITORY, a tag and a command's
failure the same way. The docs name the base branch as main or master wherever it comes up, say
which rule release-prep applies and when, and note the two step outputs the action does not expose.
The version becomes the tag and the title of the release, so it has to be the one release-prep wrote
into the branch. The action now reads every file the branch's .puprc lists under paths.versions,
with the regex pup uses for each, and refuses before merging when any holds a different version, when
one holds none, or when the branch has no .puprc. The reader lives in .github/lib/puprc.js.
The release dashboard now sends the deploy step the branch, as it sends prepare, so the template
declares branch and passes it to the action as it is.
…ex JavaScript rejects

A publish value that is neither true nor false failed only in the publish step, after both merges
were pushed; the merge step reads it now too, so the run stops before anything is merged. A .puprc
regex pup accepts but JavaScript does not surfaced as a bare SyntaxError; the error now names the
file and the regex. The README says where in the sequence the tag ancestry check runs.
@glaubersilva
glaubersilva marked this pull request as ready for review October 30, 2026 21:41
@glaubersilva
glaubersilva requested a review from d4mation October 31, 2026 01:35
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