Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI

on:
push:
branches: [master]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.4', '8.5']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: sodium
coverage: none
tools: composer:2.8.12

- name: Install
run: composer install --no-interaction --prefer-dist

- name: Lint
run: composer run lint

- name: Test
run: composer run test
40 changes: 40 additions & 0 deletions .github/workflows/module-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ on:
required: false
default: module.zip
type: string
host-version:
description: Bundled modules only. The InvoiceShelf release (a tag such as 3.0.0-alpha.12) whose composer.lock the bundle is built against; it must satisfy compatibility.invoiceshelf.
required: false
default: ''
type: string
secrets:
MODULE_SIGNING_SECRET_KEY_B64:
required: true
Expand All @@ -29,6 +34,10 @@ on:
description: Name for the deterministic module ZIP
required: true
default: module.zip
host-version:
description: Bundled modules only. InvoiceShelf release tag to build the bundle against
required: false
default: ''

permissions:
contents: read
Expand All @@ -42,6 +51,7 @@ jobs:
MODULE_ARTIFACT_NAME: ${{ inputs['artifact-name'] || 'module.zip' }}
MODULE_SIGNING_KEY_ID: ${{ vars.MODULE_SIGNING_KEY_ID }}
MODULE_MARKETPLACE_INGEST_URL: ${{ vars.MODULE_MARKETPLACE_INGEST_URL }}
MODULE_HOST_VERSION: ${{ inputs['host-version'] || '' }}
steps:
- uses: actions/checkout@v4
with:
Expand All @@ -63,6 +73,8 @@ jobs:
php-version: '8.4'
extensions: sodium
coverage: none
# Pinned: the bundle a release ships is resolved by this Composer.
tools: composer:2.8.12

- uses: actions/setup-node@v4
with:
Expand All @@ -78,6 +90,29 @@ jobs:
pnpm install --frozen-lockfile
pnpm run build
fi
if [ "$(jq -r '.dependency_policy' module.json)" = bundled ]; then
if [ -z "$MODULE_HOST_VERSION" ]; then
echo 'Bundled modules need the host-version input: the InvoiceShelf release whose composer.lock the bundle is built against.' >&2
exit 1
fi
if [ ! -f bundle.lock ]; then
echo 'Bundled modules must commit bundle.lock; run vendor/bin/invoiceshelf-module bundle locally and commit it.' >&2
exit 1
fi
php -r '
require "vendor/autoload.php";
$constraint = json_decode(file_get_contents("module.json"), true)["compatibility"]["invoiceshelf"];
if (! Composer\Semver\Semver::satisfies(getenv("MODULE_HOST_VERSION"), $constraint)) {
fwrite(STDERR, "host-version ".getenv("MODULE_HOST_VERSION")." does not satisfy compatibility.invoiceshelf {$constraint}.\n");
exit(1);
}
'
curl --fail --silent --show-error --location \
"https://raw.githubusercontent.com/InvoiceShelf/InvoiceShelf/$MODULE_HOST_VERSION/composer.lock" \
--output "$RUNNER_TEMP/host-composer.lock"
# Installs exactly bundle.lock; fails if it no longer fits this host or composer.json.
vendor/bin/invoiceshelf-module bundle . --host-lock="$RUNNER_TEMP/host-composer.lock"
fi
vendor/bin/invoiceshelf-module validate-module module.json
vendor/bin/invoiceshelf-module validate-package .
jq -r '.assets[]' module.json | while IFS= read -r asset; do
Expand Down Expand Up @@ -106,6 +141,11 @@ jobs:
rm -rf "$package/dist"
cp -a dist "$package/dist"
fi
# bundle/ is generated and untracked, like dist/.
if [ -d bundle ]; then
rm -rf "$package/bundle"
cp -a bundle "$package/bundle"
fi
find "$stage" -exec touch -h -d "@$source_date_epoch" {} +
(
cd "$stage"
Expand Down
46 changes: 41 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The MIT-licensed SDK for official [InvoiceShelf](https://invoiceshelf.com) v3 modules. It extends [`nwidart/laravel-modules`](https://github.com/nWidart/laravel-modules) with the contracts used by InvoiceShelf's module host and signed marketplace packages.

SDK **3.4** supports Module API **1.3**. First-party module packages live in their own repositories and are licensed `AGPL-3.0-only`; this SDK remains MIT.
SDK **3.5** supports Module API **1.4**. First-party module packages live in their own repositories and are licensed `AGPL-3.0-only`; this SDK remains MIT.

## What a module can add

Expand All @@ -13,16 +13,16 @@ SDK **3.4** supports Module API **1.3**. First-party module packages live in the
- Narrow host contracts under [`src/Contracts/Host`](src/Contracts/Host) for settings, authorization, and read-only company-data queries. `CompanyDataReader` covers the AI assistant's built-in queries plus `companyMembers()` and `existingInvoiceIds()` for modules that assign work to members or stamp their entries against invoices. Module code must not depend on InvoiceShelf Eloquent models.
- AI drivers through [`src/Ai`](src/Ai): extend `AiDriver`, return `AiChatResponse`, and throw `AiException` for safe, localizable provider failures.

The host controls discovery, installation, activation, migrations, and provider registration. Official packages are not a general-purpose runtime Composer installer for arbitrary third-party code.
The host controls discovery, installation, activation, migrations, and provider registration. It never runs Composer: a module either uses only what the host already ships, or brings the third-party packages it needs pre-built in `bundle/` (see [Dependencies](#dependencies)).

## Compatibility

Declare compatibility in `module.json`, then test it against the host versions you support.

| Concern | Current contract |
| --- | --- |
| SDK | `invoiceshelf/modules` `^3.4` |
| Module API | `^1.3.0` |
| SDK | `invoiceshelf/modules` `^3.5` |
| Module API | `^1.3.0`, or `^1.4.0` for a module with bundled dependencies |
| Host application | Your explicit InvoiceShelf 3.x range |
| PHP | The range your module actually supports |

Expand Down Expand Up @@ -86,6 +86,42 @@ final class DataCleanup implements DataCleanupContract

An intentionally empty method is valid for a module with nothing beyond reversible migrations. Throwing from `cleanup()` stops the uninstall so it can be retried safely.

## Dependencies

`dependency_policy` says where a module's PHP code comes from. Nothing is resolved on the server: the host never runs Composer, and its own `vendor/` is read-only.

| Policy | composer.json may require | Use it when |
| --- | --- | --- |
| `host-provided-only` | `php`, `ext-*`, `invoiceshelf/modules` | The module needs nothing beyond Laravel, the SDK and what the host already ships. |
| `bundled` | the above plus third-party packages | The module needs a library the host does not ship, such as a payment provider's SDK. Requires schema 2 and `module_api` `^1.4.0`. |

### How a bundle is built

A bundled module declares its packages in `composer.json` as usual and runs:

```bash
vendor/bin/invoiceshelf-module bundle . --host-lock=../../composer.lock # the host's lock; --update to re-resolve
```

The command resolves the module's packages in a throwaway Composer project where every **production** package of that host is a root `replace` at its locked version. Composer therefore reuses what the host ships and installs only what it lacks; a package that needs a version the host does not ship is a resolver error, never a silent second copy. The command then writes:

- `bundle/<vendor>/<package>/…`: the package files, with normalized permissions;
- `bundle/classmap.php`: every class, relative to `bundle/`, which the host reads as data and loads **after** its own autoloader, so a host copy always wins;
- `bundle/bundle.json`: each package's version, reference, license, PHP range and extensions, the host packages the module relies on (constraints kept per requirer), and any Composer `files` entries with the functions they declare;
- `bundle.lock` next to `composer.json`: the pinned resolution.

Commit `bundle.lock` and review its diff like code: signing a release vouches for everything inside it. `bundle/` itself is generated: gitignore it, and keep your own tools out of it (for Pint, `"exclude": ["bundle"]` in `pint.json`). Without `--update` the command installs exactly `bundle.lock` and fails when it no longer fits the host or `composer.json`, or when your own `composer.lock` holds different versions of a bundled package (tests run on `vendor/`, the archive ships `bundle/`). The same tree is written byte for byte on every machine.

The build refuses a bundle the host cannot load safely: a license an AGPL-3.0-only module cannot convey (Composer's license list counts as alternatives), a Composer plugin, a class or function under `App\`, `Composer\`, `Database\`, `Illuminate\`, `InvoiceShelf\`, `Laravel\`, `Modules\`, `Nwidart\` or `Tests\`, an extension that `compatibility.extensions` does not list (core extensions such as `ext-json` and `ext-pcre` excepted), a symbolic link, or more than 9,000 files. It warns when a bundled package is also a development dependency of the host, because a local host then loads its own copy.

### What the host checks

On marketplace install, local `install:module`, enabling a module, and every container start (`modules:reconcile`), the host refuses or disables a bundled module when an installed host package no longer satisfies a recorded constraint, a bundled package's PHP range excludes the running PHP, a bundled package or class is also provided by the host or another module, or a bundled function already exists. Composer `files` entries are loaded only for enabled modules.

### Local development

Build the bundle against the lock of the host you run (for a sibling checkout, `--host-lock=../InvoiceShelf/composer.lock`; for a module placed in the host's `Modules/`, `--host-lock=../../composer.lock`), then `php artisan install:module <Name> <version>`. Rebuild when you change a package or update the host. A local host has its development packages installed, and those win over a bundled copy; the release workflow builds against the production package set.

## Developing a module

Install the SDK in the module project, then use Laravel Modules as usual:
Expand Down Expand Up @@ -213,7 +249,7 @@ vendor/bin/invoiceshelf-module validate-module module.json
vendor/bin/invoiceshelf-module validate-package .
```

`validate-package` checks the manifest, providers, migrations, declared assets, and the closed host-provided dependency policy. The CLI also validates and canonicalizes generated `release-manifest.json` files.
`validate-package` checks the manifest, providers, migrations, declared assets, and the dependency policy: for a bundled module, also `bundle/` against `bundle.json` (reading `classmap.php` as data, never running it). The CLI also validates and canonicalizes generated `release-manifest.json` files.

## Releasing an official module

Expand Down
14 changes: 12 additions & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This document is for maintainers of official module repositories. It describes t
## Before tagging

1. Set `module.json`'s `version` to the intended exact SemVer version.
2. Build and commit every declared `dist/` asset.
2. Build and commit every declared `dist/` asset. A bundled module also commits an up-to-date `bundle.lock`, reviewed like code.
3. Run the module's lint, tests, frontend build, and package validation.
4. Merge the release change, then create an **unprefixed** tag that exactly matches `module.json`, for example `1.2.3` rather than `v1.2.3`.

Expand All @@ -18,12 +18,22 @@ An official module normally calls the tagged SDK workflow from its own tag workf
```yaml
jobs:
release:
uses: InvoiceShelf/modules/.github/workflows/module-release.yml@3.4.0
uses: InvoiceShelf/modules/.github/workflows/module-release.yml@3.5.0
with:
channel: stable
secrets: inherit
```

A module with `dependency_policy: bundled` also passes `host-version`, the InvoiceShelf release tag whose `composer.lock` the bundle is built against. Use the oldest release the module supports: it must satisfy `compatibility.invoiceshelf`, and newer hosts are checked against the recorded host packages at install time.

```yaml
with:
channel: stable
host-version: 3.0.0-alpha.12
```

The workflow then downloads that lock, installs exactly the committed `bundle.lock` (it never re-resolves) and copies the generated `bundle/` into the archive next to `dist/`. Composer is pinned in the workflow so the same lock always yields the same bytes.

The workflow installs dependencies, runs Pint and PHPUnit, builds frontend assets when present, validates the package, creates a timestamp-normalized ZIP, generates and validates `release-manifest.json`, signs its canonical JSON, and sends it to marketplace ingest.

## Protected `module-release` environment
Expand Down
40 changes: 38 additions & 2 deletions bin/invoiceshelf-module
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

declare(strict_types=1);

use InvoiceShelf\Modules\Bundle\BundleBuilder;
use InvoiceShelf\Modules\Bundle\ComposerProcess;
use InvoiceShelf\Modules\Manifest\ManifestValidator;
use InvoiceShelf\Modules\Manifest\SigningKeyPairGenerator;

Expand All @@ -14,14 +16,48 @@ if (! is_string($autoloadPath) || ! is_file($autoloadPath)) {

require $autoloadPath;

[$command, $target] = array_pad(array_slice($argv, 1), 2, null);
$positional = [];
$options = [];
foreach (array_slice($argv, 1) as $argument) {
if (preg_match('/^--([a-z-]+)(?:=(.*))?$/', $argument, $matches) === 1) {
$options[$matches[1]] = $matches[2] ?? true;
} else {
$positional[] = $argument;
}
}
[$command, $target] = array_pad($positional, 2, null);

if (! in_array($command, ['validate-module', 'validate-package', 'validate-release', 'canonicalize-release', 'generate-keypair'], true) || ! is_string($target)) {
if (! in_array($command, ['validate-module', 'validate-package', 'validate-release', 'canonicalize-release', 'generate-keypair', 'bundle'], true) || ! is_string($target)
|| ($command === 'bundle' && ! is_string($options['host-lock'] ?? null))) {
fwrite(STDERR, "Usage: invoiceshelf-module <validate-module|validate-package|validate-release|canonicalize-release> <path>\n");
fwrite(STDERR, " invoiceshelf-module generate-keypair <key-id>\n");
fwrite(STDERR, " invoiceshelf-module bundle <module-dir> --host-lock=<host composer.lock> [--update] [--composer=<binary>]\n");
exit(64);
}

if ($command === 'bundle') {
try {
$composer = is_string($options['composer'] ?? null) ? $options['composer'] : null;
$report = (new BundleBuilder(ComposerProcess::locate($composer)))->build($target, $options['host-lock'], ($options['update'] ?? false) === true);
} catch (Throwable $exception) {
fwrite(STDERR, "Bundle failed: {$exception->getMessage()}\n");
exit(1);
}

fwrite(STDOUT, ($report->lockWritten ? 'Resolved and wrote bundle.lock. ' : 'Installed from bundle.lock. ')
.($report->manifest->packages === [] ? "bundle/ ships nothing: the host provides every requirement.\n" : "bundle/ ships:\n"));
foreach ($report->manifest->packages as $name => $package) {
fwrite(STDOUT, " {$name} {$package['version']} (".implode(' or ', $package['license']).")\n");
}
if ($report->manifest->hostPackages !== []) {
fwrite(STDOUT, 'Relies on the host for: '.implode(', ', array_keys($report->manifest->hostPackages))."\n");
}
foreach ($report->warnings as $warning) {
fwrite(STDERR, "Warning: {$warning}\n");
}
exit(0);
}

try {
if ($command === 'generate-keypair') {
$keypair = SigningKeyPairGenerator::generate($target);
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"license": "MIT",
"type": "library",
"require": {
"composer/semver": "^3.4",
"nikic/php-parser": "^5.0",
"php": "^8.3",
"nwidart/laravel-modules": "^13.0"
Expand Down
Loading
Loading