B2B Buy Now, Pay Later for Magento 2.3.3+. This plugin integrates Two as a payment method, letting merchants offer invoice-based checkout with flexible net terms.
For merchants:
- Instant credit checks on business customers
- B2B guest checkout (up to 36% conversion uplift)
- Flexible invoice terms from 14 to 90 days
- Automatic invoicing via the PEPPOL e-invoicing network
- Partial capture and refunds
- Instant payment on fulfilment — Two assumes the credit risk
- Optional product-page promotion: a message and a buy button, both off by default (how to turn them on)
For buyers:
- Frictionless checkout with no onboarding
- Flexible repayment terms
- PDF and electronic invoicing straight to their ERP
Install via Composer:
composer require two-inc/magento2
php bin/magento module:enable Two_Gateway
php bin/magento setup:upgrade
php bin/magento cache:flushIn production mode, also deploy static content:
php bin/magento setup:static-content:deployThen configure the plugin under Stores > Configuration > Sales > Payment Methods > Two.
setup:upgrade enables the module's two_gateway cache type, which holds the
merchant profile values fetched from Two. Drop just those values with:
php bin/magento cache:clean two_gatewayThe type must be enabled for that to do anything: a disabled cache type accepts the command and drops nothing.
Run these immediately after setup:upgrade to refresh the DI graph
and clear any stale cache types:
php bin/magento setup:di:compile
php bin/magento cache:flushIf admin Configuration is missing expected Two/brand sections, the cause is almost certainly one of:
- A plugin registered only under
etc/adminhtml/di.xmloretc/crontab/di.xmlinstead ofetc/di.xml. See AGENTS.md for the DI-scope rule. - An FPM worker holding stale opcache. Restart PHP-FPM
(
systemctl reload php-fpmorkill -USR2 <fpm-master-pid>). - A cache type (config / layout / full_page) in stale state.
bin/magento cache:flushis the canonical fix.
Two optional surfaces on the product page, added in 2.4.0. Both are off by default and independent: a shop may run either, both or neither.
Find them in the admin under Stores → Configuration → Two → Checkout fields → Title & display.
| Setting | What it does |
|---|---|
| Show message on product pages | Adds a short line telling the buyer they can pay on invoice with Two. |
| Show buy button on product pages | Adds a button under Add to Cart that puts the item in the basket and opens checkout with Two preselected where it is available. |
The button never places the order. It carries the buyer to checkout, and they confirm there as usual, so a misclick costs nothing. It sits beside the theme's Add to Cart rather than replacing it, and respects the quantity and any variant the buyer has chosen.
Preselection is conditional, by design. A product page has no cart, so
the button cannot know whether minimum order value, buyer country or
currency will allow the method. When the finished basket does not qualify,
checkout opens with nothing selected and the buyer chooses as they would
have anyway. See Block/Product/ExpressButton.php for the gates the button
does apply.
Brand overlays only see what they list. From 3.0.0 an overlay renders
only the paths in its <allowed_fields>, so these controls appear under an
overlay's own tab only if it names
checkout_fields/display/product_message_enabled and
checkout_fields/display/product_button_enabled. See
docs/brand-overlay-guide.md.
Both settings are per store view, so you can trial them on one storefront before rolling them out.
4.0 removes the plugin's own shipping tax settings: the Default shipping tax
class field and the deprecated flat-percentage Default shipping tax rate
field. setup:upgrade deletes their stored values and does not carry them
over anywhere.
Where Magento records no tax rate for a shipping line, the fallback now uses Magento's own Stores > Configuration > Sales > Tax > Tax Classes > Tax Class for Shipping, and only on stores where the fallback has been enabled (see below). Anywhere else such a line is sent at 0% with its tax as charged, and Two's API validates it. This includes orders placed on 3.x.
The shipping tax fallback resolves a shipping line's rate from Magento's own Tax > Tax Classes > Tax Class for Shipping when Magento recorded no rate for that line. It is off by default and has no admin field. Contact Two before enabling it for a store:
bin/magento config:set --scope=stores --scope-code=<store_code> payment/two_payment/enable_shipping_tax_fallback 1
bin/magento cache:flush configOn a brand overlay the path is payment/<brand code>/enable_shipping_tax_fallback.
The fallback is populated when it is enabled for the store and the Tax Class for Shipping is set. Enabled with no class set, it does nothing.
| Shipping line | Fallback blank (the default) | Fallback populated |
|---|---|---|
| Magento recorded a rate, including an explicit 0% | Sent at the recorded rate. The plugin does not check it; the postprocessing hook runs, then Two's API validates it. | Same as blank. |
| Magento recorded no rate, whatever the line's tax (0 included) | Sent as is: rate 0, tax as charged. The plugin does not check it; the hook runs, then Two's API validates it. | The rate comes from the Tax Class for Shipping, and the line's tax must reconcile with it within 0.02. If it does not, the request is refused; if it does, the line is sent at that rate and the hook runs. |
With the fallback blank, the plugin never refuses a request over shipping tax.
"Recorded a rate" means Magento applied a tax rate to the shipping line when the order was placed. A Tax Class for Shipping of None, or no tax rule matching the address, applies none, so it reads as no rate. A rule applying 0% is recorded when shipping prices exclude tax.
Placement stores which case applied on the order, in two_shipping_tax_rate_source
(declared or none), with the rate in two_shipping_tax_rate (percent): the
recorded rate, 0 included, or for none the fallback's rate, empty when the
fallback was blank. Magento does not save a 0% shipping rate with the order,
so this record is what update, capture, shipment and refund read, never the
current configuration: a later change to the fallback or the tax rules does not
move an order already placed. An order placed before this record existed has
both empty and resolves as it would at placement. A refund takes the order's
shipping rate and does not re-check the tax it carries.
The check runs while the plugin builds the request, before the postprocessing hook, and never on what a subscriber returns. If your subscriber re-splits a shipping line that has no recorded rate, keep the fallback blank: a populated fallback can refuse that line before your subscriber ever sees it.
Every line the plugin sends at a 0% tax rate can carry a Two tax_code saying
why it is zero. For a merchant whose Two account is in Spain the API requires
one on every 0% line. The plugin adds it to order create, order edit, partial
capture, shipment and refund lines, while it builds the request and before the
postprocessing hook, so a subscriber can still change it. Lines at any other
rate are sent exactly as before.
A line's code comes from the first of these that gives one:
-
Your mapping. Order management > Tax codes for 0% lines has one row per product tax class (including None), each with a dropdown of Two's tax codes for your country, fetched from Two and cached for a day. A product line uses its product's tax class (a configurable product, its child's). The shipping line uses Magento's Tax Class for Shipping (marked "(shipping)" in the list; with that set to None, map the None row), and the payment terms fee its own surcharge tax class. Every row defaults to (none). Codes that need an exemption reason the plugin has no way to supply are not offered; set those, with their reason, in the postprocessing hook.
-
Derivation, for merchants in Spain only. Physical products are goods; virtual and downloadable products are services, and so is any item Magento marks virtual, such as a bundle, gift card or configurable product with nothing to ship. Shipping and other fee lines count as goods when the order has a physical product, and as services when it has none. Goods follow the delivery address (the billing address when there is none). Services follow the buyer company's country, which is the billing country the plugin sends.
Line Where Code Goods Delivered outside the EU ES_IVA_EXPORTGoods Delivered to the Canary Islands, Ceuta or Melilla (Spanish postcodes starting 35, 38, 51 or 52) ES_IVA_EXPORTGoods Delivered to another EU state, buyer in an EU state other than Spain ES_IVA_INTRA_COMMUNITYGoods Delivered in mainland Spain or the Balearics, or to another EU state for a Spanish buyer none Service Buyer in an EU state other than Spain ES_IVA_REVERSE_CHARGEService Buyer in Spain or outside the EU none Monaco counts as part of the EU (through France). Two only sells to verified businesses, so every buyer counts as a business.
-
Otherwise no code is sent.
The plugin never refuses; the API does. A 0% line with no code is sent as is, and Two's API decides. For a Spanish merchant it refuses such a line, so map the tax classes that produce 0% lines nothing above covers (for example domestic exempt sales, or services to buyers outside the EU). A non-Spanish merchant with no mapping sends exactly what it sent before.
Placement records each line's code, or that it had none, on the order
(two_tax_codes): per product line, for shipping, for the payment terms fee,
and once for all other fee lines ("Other charges" and fee-provider lines),
which share one code whatever their id. Order edit, capture, shipment and
refund send those codes, so a later change to the addresses, the mapping or a
product's tax class does not move a placed order. These resolve afresh
instead: the refund adjustment line, a product line placement could not match
to its item (its SKU was changed by another extension, or its item has no
quote item), a fee line on an order that had none at placement, and every
line of an order placed before this record existed.
Every request this plugin sends to Two about an order passes through one extension point first. It presents the entire request body and lets your code change any part of it: split a charge differently, change an amount, add a field. Use it when the shop is not your accounting source of record and the invoice Two issues has to follow your books rather than the shop's figures.
Name and mechanism. An after plugin on
Two\Gateway\Api\OrderPostprocessingInterface::process(array $payload, array $context): array.
The default implementation returns the payload unchanged. Plugins chain by
sortOrder, and the final result is sent as returned.
<!-- your module's etc/di.xml -->
<type name="Two\Gateway\Api\OrderPostprocessingInterface">
<plugin name="acme_order_postprocessing" type="Acme\Two\Plugin\OrderPostprocessing"/>
</type>Parameters.
$payload: the complete request body exactly as it would be sent, amounts as 2dp decimal strings:line_items,tax_subtotals,net_amount,tax_amount,gross_amount, buyer, addresses and the rest. A partial capture carries its lines underpartial; a refund carriesamount. A request with no body (confirm, cancel, whole-order capture) is[]. A 0% line may carrytax_code(see "Tax codes on 0% lines"); a subscriber may change it, and may addtax_exemption_reason_codefor a code that needs a reason the plugin cannot supply.$context, an array:
| Key | Type | Meaning |
|---|---|---|
request_type |
string | order_intent, order_create, order_update, order_confirm, capture, refund or cancel |
trigger |
string | What caused the request: checkout, admin_edit, confirmation, invoice, shipment, status_change, credit_memo, cancel, buyer_cancel |
endpoint |
string | The API path, for example /v1/order/{id}/refund |
quote or order |
object | The quote for order_intent, the order for everything else |
invoice, shipment, creditmemo |
object | Present where the request has one |
shipping_tax_rate |
float or null | The rate Magento's Tax Class for Shipping applies at the order's tax address, whether or not the shipping line was taxed. 0.21 means 21%. Null when no class is set |
fallback_shipping_tax_rate |
float or null | The shipping tax fallback's rate, null unless the fallback is enabled for the store |
contract_version |
int | 1 |
Return. The full payload, edited or not.
When it fires. Immediately before every order request is sent, once per request:
| Request type | When |
|---|---|
order_intent |
The checkout's approval check. The body is built here from the quote, with the same lines as the order |
order_create |
Order placement |
order_update |
An admin edit of the order's address. Not sent once the order can no longer be edited, for example once Two has invoiced all or part of it: the admin sees a notice instead |
order_confirm |
The buyer returns from Two's checkout |
capture |
An online invoice, a shipment, or the order reaching a fulfil-on status, per the fulfilment trigger |
refund |
A credit memo |
cancel |
The order is cancelled or voided, or the buyer abandons Two's checkout |
A few specifics:
- A whole-order capture has no body and its
invoiceis null. That covers an invoice for everything still open and the fulfil-on status trigger (which carries noinvoicekey at all). - If Two answers a whole-order capture with
PARTIAL_ORDER_MISSING_DATA, the plugin retries it as a partial capture of the latest invoice, so the hook fires twice for one capture: first with[], then with thepartialbody. - For
order_intentthe plugin builds an unsaved order from the live checkout quote. It re-collects the quote's totals and runs core's quote-to-order conversion in memory, which dispatchessales_convert_quote_to_order. That happens on every approval check, and no order is placed. An observer on that event, or on the totals collection, that assumes a real placement (reserving stock, numbering, writing records) will run then too.
Unchanged means unchanged. When no subscriber changes the payload, every request is sent byte for byte as the plugin composed it, and is accepted or refused exactly as it was before this hook existed.
What you return is sent. The payload goes out exactly as your subscriber
returns it, and Two's API validates it as it validates any request. The plugin
checks only what it builds itself: before the hook runs, order create and
address update refuse a product or fee line the plugin composed whose tax does
not follow from its rate, as they always have. The shipping line is checked
only as the shipping tax fallback describes. If the API rejects your payload, its
response is written to var/log/two/debug.log and its message is shown to the
admin for an admin action, or to the buyer at checkout.
A subscriber that throws, returns something other than an array, or returns a
payload that cannot be JSON-encoded has a bug. That request is refused and
logged with TWO_ORDER_POSTPROCESSING_HOOK_FAILED:
| Request type | Effect of a refusal |
|---|---|
order_intent |
The approval check is refused and the buyer sees a generic notice |
order_create |
Checkout is refused with a generic notice |
order_update |
The update is not sent to Two. The address edit still saves in Magento, and the admin sees the error as a warning and in the order's history |
capture |
The invoice, shipment or fulfil-on status change that triggered it is blocked with the error |
refund |
The credit memo is refused with the error |
order_confirm, cancel |
Never refused. These take no body, so a subscriber that throws or adds one is logged with TWO_ORDER_POSTPROCESSING_HOOK_FAILED or TWO_ORDER_POSTPROCESSING_BODY_NOT_ACCEPTED, and the request is sent empty. Magento has already confirmed or cancelled the order by then, and a Two order left live could still be invoiced |
The plugin never recomputes anything after the hook, because that would
overwrite your edits. If you change a line, move the totals and subtotals it
affects, or the API will reject the request: inject
Two\Gateway\Api\OrderPostprocessingTotalsInterface and call
recompute($edited, $before), passing the payload you received as $before.
It sets net_amount, tax_amount and gross_amount (or a refund's amount)
to the sum over your lines plus whatever the total carried outside its lines
in $before (store credit, a gift card, an unitemised fee), and does the same
for each tax_subtotals bucket by rate. A bucket residual under half a cent is
float noise and is dropped. A total you set by hand before the call is replaced, not
counted twice.
Every change is written to the debug log with the request type and each changed field's JSON pointer, old and new value, and noted in the order's comment history where there is an order.
Your code owns what it declares. With a subscriber that changes amounts, the invoice Two issues can differ from what the shop charged. That is your decision, and nothing in the plugin compares your result with the shop's own totals.
Requirements on a subscriber.
- Deterministic: the same inputs give the same output.
- Cheap: it runs on every approval check during checkout as well as on every order request.
- Present: a disabled module simply means the shop's own figures are sent, and the plugin cannot tell that from having no subscriber.
Example. Treat untaxed shipping as VAT-inclusive at the shop's shipping tax
rate. A 29.00 shipping line becomes net round(29.00 / 1.21, 2) = 23.97 and tax
5.03, gross unchanged. A partial capture carries its lines under partial, so
the edit covers both places:
namespace Acme\Two\Plugin;
use Two\Gateway\Api\OrderPostprocessingInterface;
use Two\Gateway\Api\OrderPostprocessingTotalsInterface;
class OrderPostprocessing
{
public function __construct(private OrderPostprocessingTotalsInterface $totals)
{
}
public function afterProcess(OrderPostprocessingInterface $subject, array $result, array $payload, array $context): array
{
$rate = $context['shipping_tax_rate'];
if (!$rate) {
return $result;
}
$resplit = static function (array $line) use ($rate): array {
if ($line['type'] !== 'SHIPPING_FEE' || (float)$line['tax_amount'] != 0.0) {
return $line;
}
$gross = (float)$line['gross_amount'];
$net = round($gross / (1 + $rate), 2);
$line['net_amount'] = number_format($net, 2, '.', '');
$line['tax_amount'] = number_format($gross - $net, 2, '.', '');
$line['unit_price'] = $line['net_amount'];
$line['tax_rate'] = number_format($rate, 6, '.', '');
$line['tax_class_name'] = 'VAT ' . number_format($rate * 100, 2) . '%';
return $line;
};
$edited = $result;
if (isset($edited['line_items'])) {
$edited['line_items'] = array_map($resplit, $edited['line_items']);
}
if (isset($edited['partial']['line_items'])) {
$edited['partial']['line_items'] = array_map($resplit, $edited['partial']['line_items']);
}
return $this->totals->recompute($edited, $result);
}
}Test/Integration/OrderPostprocessingFixture is a working module that does
this, and CI runs it inside a real Magento on every change.
Versioning. This contract is permanent. The interface, its method and the v1 context keys and request types are never removed or renamed, and it fires consistently in response to the same events in every release. New context keys, new request types or triggers, and relaxed checks may be added without notice. Removing a key, changing a unit (rates stay decimal fractions), tightening a check a v1 subscriber could already pass, or firing on fewer requests is never done. A genuinely incompatible change would arrive as a new interface, with this one still firing alongside it.
The development environment runs Magento in Docker with the plugin bind-mounted, so file changes are reflected immediately.
docker-compose.yml brings up three containers: Magento, MariaDB and OpenSearch.
The Magento image (ghcr.io/brtkwr/magento-dev, built from
brtkwr/magento-helm) is published for
both amd64 and arm64, so on Apple Silicon it runs natively instead of under
QEMU emulation. It also means no Magento marketplace keys are needed — the
plugin is mounted as an app/code module and the language packs ship inside the
image, so nothing runs composer against repo.magento.com on your machine.
- Docker
- Make
- A Two API key (request sandbox access)
# Create the Magento container and install the plugin
make install
# Configure with your API key
make configure TWO_API_KEY=<your-key>
# Start / stop
make run
make stopThe first make install takes a few minutes: the container installs the shop
on first boot, and make install waits for it to finish before configuring
anything.
After install, Magento is available at http://localhost:1234/ (admin: http://localhost:1234/admin, credentials: exampleuser@two.inc / examplepassword123).
To use a different port: make install PORT=5678.
By default, the plugin points at Two's staging environment for @two.inc gcloud accounts, or sandbox for everyone else. You can override the API and checkout URLs explicitly:
make install TWO_API_BASE_URL=http://localhost:8000 TWO_CHECKOUT_BASE_URL=http://localhost:3000In production mode these are ignored — the URLs are derived from the mode setting in the admin panel (sandbox/staging/production).
make install, make run and make debug print the resolved API / checkout-page hosts (honouring the overrides above and the developer-mode gate) in their status block, so you can see at a glance which real hosts your local instance will actually talk to without having to run dev/probe-hosts.php yourself.
Run make help to see all available targets.
make install runs module:disable on a fixed list of modules that aren't needed for plugin development but add significant load to setup:di:compile (every module's DI is re-generated) and to the storefront's RequireJS dependency graph (every enabled module's JS gets pulled into the boot, even on pages that don't use it). Disabling them cuts setup:di:compile time and drops storefront button-enable latency from ~10s to under a second on the sample-data catalog.
| Module(s) | Why it's disabled in dev |
|---|---|
Magento_AdminAdobeImsTwoFactorAuth, Magento_TwoFactorAuth |
TOTP setup required on every admin login — friction for local dev |
Magento_Analytics, Magento_AdminAnalytics, Magento_CatalogAnalytics, Magento_CustomerAnalytics, Magento_QuoteAnalytics, Magento_ReviewAnalytics, Magento_SalesAnalytics, Magento_WishlistAnalytics, Magento_GoogleAnalytics, Magento_GoogleOptimizer |
JS/tracking hooks that fire on every storefront load |
Magento_PageBuilder, Magento_PageBuilderAnalytics, Magento_CatalogPageBuilderAnalytics, Magento_CmsPageBuilderAnalytics, Magento_PageBuilderAdminAnalytics, Magento_AwsS3PageBuilder |
Loads the full PageBuilder ContentTypes JS tree on every storefront page — biggest single contributor to client-side boot time |
Magento_NewRelicReporting is not disabled — Magento_GraphQl declares a hard dependency on it, and disabling it cascades through every GraphQL module. It stays quiet at runtime when un-licensed.
Consequence: PageBuilder-driven CMS content (banners, slides, promo blocks edited via the visual editor) will not render in a make install environment. If you're testing brand content that relies on PageBuilder blocks, re-enable them manually inside the container:
docker exec magento php bin/magento module:enable Magento_PageBuilder Magento_PageBuilderAnalytics Magento_CatalogPageBuilderAnalytics Magento_CmsPageBuilderAnalytics Magento_PageBuilderAdminAnalytics Magento_AwsS3PageBuilder
docker exec magento php bin/magento setup:upgrade
docker exec magento php bin/magento setup:di:compile
docker exec magento php bin/magento cache:flushInstall also runs:
config:set dev/js/merge_files=1,dev/js/minify_files=1,dev/css/merge_css_files=1— flatten the inline RequireJS bootstrap into a single merged bundle in the HTML.setup:static-content:deploy --area frontend --theme Magento/luma --no-html-minify -f --jobs 4 en_US— pre-bake the Luma theme so RequireJS's ~hundreds of runtime XHRs hit plain file IO instead of falling through Magento'spub/static.phprouter (a full Magento bootstrap per asset). Without this, on the sample catalog the storefront's "Add to Cart" button-enable latency is ~10s; with it, ~1s warm.
Brand-specific overlay packages live in their own Composer packages and are
installed separately from this plugin, not through make install. See
docs/brand-overlay-guide.md for how overlay modules are structured.
Xdebug is installed automatically by make install but is disabled by default. To start in debug mode:
make debugThis activates Xdebug (port 9003) and disables all Magento caches for hot reload — PHP changes, templates, layout XML, and config changes are picked up on the next request without manual cache flushing. The only exception is DI wiring changes (new classes, plugins, or preferences in di.xml), which still require make compile.
Setting breakpoints in VSCode:
- Install the PHP Debug extension
- Press F5 to start listening (uses the included
.vscode/launch.json) - Click the gutter next to any line in the plugin code to set a breakpoint
- Browse to the Magento store — every request will trigger the debugger automatically
The debugger will pause at your breakpoint with full access to variables, call stack, and step-through execution.
For testing integrations that require HTTPS callbacks (e.g. the Two checkout flow), you can expose your local instance via an FRP reverse proxy.
Setup (one-time): install the FRP client (frpc):
- macOS:
brew install frpc - Linux: download from GitHub releases and place
frpcon your PATH
Authentication:
The proxy needs an FRP_AUTH_TOKEN to connect to the FRP server. The start-proxy.sh script resolves the token in this order:
- Command-line argument:
./start-proxy.sh <token> - Environment variable:
export FRP_AUTH_TOKEN=<token>(or set it in.env) - GCP Secret Manager: falls back to
gcloud secrets versions access latest --secret=FRP_AUTH_TOKEN --project=two-beta
Edit frpc.toml to point at your FRP server, then provide the token via any of the methods above.
Usage:
# Proxy starts automatically with make run / make debug.
# To run the proxy standalone in the foreground:
make proxy# Unit tests
make test
# End-to-end API tests (requires a valid API key)
make test-e2e TWO_API_KEY=<your-key>| Target | Description |
|---|---|
make compile |
Recompile Magento DI (after adding/changing PHP classes, plugins, or preferences) |
make logs |
Tail the Two plugin debug and error logs |
make format |
Run Prettier on frontend JS/CSS/templates |
make clean |
Stop and remove the Magento container |
The version is computed on the pull request that lands on staging, from that PR's own commits. main computes nothing — it tags the version already in the tree and cuts the GitHub Release.
| Change | What happens |
|---|---|
PR into staging |
the version is computed from that PR's own commits and committed onto the PR's branch (.github/workflows/version-bump.yml) |
merge into staging |
nothing — the merge brings in the version its PR computed |
staging into main |
nothing is computed; main tags the version already in the tree and cuts the GitHub Release |
With M the version on origin/main and C the version on the PR head, the PR's own commits (origin/staging..HEAD, --no-merges) decide the candidate: a ! type or a BREAKING CHANGE: footer gives (M.major + 1).0.0, a feat: gives M.major.(M.minor + 1).0, and anything else — fix and chore/docs/ci/test/refactor alike — gives M.major.M.minor.(M.patch + 1). The result is clamped with max(C, candidate), which makes it idempotent (a re-run, the synchronize the bump commit itself fires, or a second fix commit on the same PR all write nothing) and means the version can never regress while main is behind staging.
A major is an explicit escape hatch, and overrides the rule above. Two independent signals, the higher wins:
-
Declared — a root
.next-majorfile whose first whitespace-delimited token is the target major, with a short human reason on the same line:3 # overlay migration, 3.0.0 releaseReviewable in the PR that decides it, so a planned major with no single breaking commit still lands as a major. The file is never cleared by CI: it disarms itself once the major it names has shipped. A declaration that has fallen below the major on
mainis a hard CI failure — delete or raise it. -
Discovered — a
!on a conventional-commit type (feat!:,TWO-1/fix(scope)!:) or aBREAKING CHANGE:footer in this PR's own commits only. Deliberately not the cumulativemain..stagingrange: a break that already landed onstagingmust not be re-discovered by every later PR.
The new version for a major is exactly <target>.0.0, so a declaration may skip more than one major.
.github/scripts/decide-bump-level.sh owns this decision, is unit-tested by .github/scripts/test-decide-bump-level.sh, and is shared byte-identically across the plugin repos. It logs the full decision — inputs included — to the workflow log on every run.
.github/workflows/version-bump.yml runs on every pull_request into staging. It:
- Runs the unit tests for the computation, then computes an absolute version with
.github/scripts/decide-bump-level.sh origin/staging HEAD. - If that version differs from the one on the PR head, runs
bumpver update --set-version <X.Y.Z> --no-tag-commit --no-pushto rewritecomposer.json,etc/config.xmlandbumpver.toml, and pushes the commit onto the PR's own branch under the org GitHub App identity. Otherwise it writes nothing.
The push goes out under the App token rather than GITHUB_TOKEN for two reasons: GITHUB_TOKEN pushes do not trigger workflows, so CI would never re-run on the bump SHA; and the App holds the ruleset bypass. Because the commit lands on a feature branch, it is outside terraform-managed-branch-protection (which targets only refs/heads/{main,release,staging}) entirely.
.github/workflows/release.yml is triggered by the CI workflow completing on main. When CI's conclusion is success, it:
- Skips itself if the branch tip drifted from the SHA CI signed off on, or if the SHA already carries a numeric tag. (That last check is what makes the merge-back a no-op: after a
mainrelease fast-forwards intostaging, staging's tip already carries the tag.) - Reads the version out of
bumpver.toml— it does not compute or bump one. - Tags
X.Y.Z(bare numeric, matching the established tag convention), pushes the tag, and creates a GitHub Release with a bucketed changelog (Breaking / Features / Fixes / Internals / Other).
.github/workflows/merge-back.yml keeps staging fast-forwarded to match main after each release (falling back to a sync PR if the two have diverged).
.github/workflows/auto-pr.yml runs on every push to staging (a merge is a push) and keeps a single rolling staging → main promotion PR open, no-opping when one already exists or when staging is not ahead of main.
To trigger a release, merge that staging → main PR. CI runs on the merged commit; once green, release.yml fires.
.github/workflows/release-dispatch.yml fires on the published Release and notifies the infrastructure repository, which resolves the newly published version and raises the pull request that moves the release-tracking Magento staging shop onto it. Because that shop installs from Packagist, a dispatch can arrive before Packagist has indexed the new tag; the infrastructure repository's daily reconcile covers that case, so nothing here needs to wait or retry (TWO-25769).
OSL-3.0 / AFL-3.0. See composer.json for details.