Merge pull request 'feat: Module Package v1 carries the module's own license (ADR 0051)' (#74) from feat/module-package-v1-licensing into main
CI / verify (push) Successful in 1m6s
CI / infrastructure-smoke (push) Successful in 38s
CI / migration-smoke (push) Successful in 2m7s
CI / image-build (push) Successful in 15s
CI / deploy-smoke (same-origin) (push) Successful in 56s
CI / deploy-smoke (split) (push) Successful in 1m23s
CI / verify (push) Successful in 1m6s
CI / infrastructure-smoke (push) Successful in 38s
CI / migration-smoke (push) Successful in 2m7s
CI / image-build (push) Successful in 15s
CI / deploy-smoke (same-origin) (push) Successful in 56s
CI / deploy-smoke (split) (push) Successful in 1m23s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/74 Reviewed-by: The_River <[email protected]>
This commit was merged in pull request #74.
This commit is contained in:
@@ -49,7 +49,9 @@ as one **Ekklesia Module Package v1**
|
|||||||
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
|
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
|
||||||
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
|
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
|
||||||
bundled, and checked by Core's verifier before it is installed. Only the portable `any` target exists
|
bundled, and checked by Core's verifier before it is installed. Only the portable `any` target exists
|
||||||
so far. ABI-v1 packages may bundle a module-owned Drizzle version without coupling it to Core;
|
so far. Every package records the module's validated SPDX expression and carries its explicitly
|
||||||
|
supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_NOTICES`. ABI-v1
|
||||||
|
packages may bundle a module-owned Drizzle version without coupling it to Core;
|
||||||
legacy-v8 packages retain their transitional exact-version check. Purge, native-target packages,
|
legacy-v8 packages retain their transitional exact-version check. Purge, native-target packages,
|
||||||
signing, and any installer or marketplace do not exist yet.
|
signing, and any installer or marketplace do not exist yet.
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import {
|
import {
|
||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
|
type ModuleManifest,
|
||||||
|
safeParseModuleManifest,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
MODULE_PACKAGE_ANY_TARGET,
|
MODULE_PACKAGE_ANY_TARGET,
|
||||||
MODULE_PACKAGE_FORMAT,
|
MODULE_PACKAGE_FORMAT,
|
||||||
MODULE_PACKAGE_METADATA_FILENAME,
|
MODULE_PACKAGE_METADATA_FILENAME,
|
||||||
type ModuleManifest,
|
|
||||||
type ModulePackageMetadata,
|
type ModulePackageMetadata,
|
||||||
safeParseModuleManifest,
|
|
||||||
safeParseModulePackageMetadata,
|
safeParseModulePackageMetadata,
|
||||||
safeParseModulePackageRuntimePackage,
|
safeParseModulePackageRuntimePackage,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts/module-package";
|
||||||
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
|
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
|
||||||
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
|
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
|||||||
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
|
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
archiveSha256: digest,
|
archiveSha256: digest,
|
||||||
fileCount: 8,
|
fileCount: 9,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
target: "any",
|
target: "any",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -104,6 +104,7 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
|||||||
await verifyModulePackage({ archivePath, extractTo: target, platform });
|
await verifyModulePackage({ archivePath, extractTo: target, platform });
|
||||||
const names = (await readdir(target, { recursive: true })).sort();
|
const names = (await readdir(target, { recursive: true })).sort();
|
||||||
expect(names).toContain("ekklesia.package.json");
|
expect(names).toContain("ekklesia.package.json");
|
||||||
|
expect(names).toContain("LICENSE");
|
||||||
expect(names).toContain("dist/server/index.js");
|
expect(names).toContain("dist/server/index.js");
|
||||||
expect((await stat(target)).mode & 0o7777).toBe(0o755);
|
expect((await stat(target)).mode & 0o7777).toBe(0o755);
|
||||||
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
|
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
|
||||||
@@ -153,6 +154,88 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a missing license declaration", undefined],
|
||||||
|
["an empty license declaration", ""],
|
||||||
|
["UNLICENSED", "UNLICENSED"],
|
||||||
|
["a malformed SPDX expression", "MIT OR"],
|
||||||
|
])("fails package metadata with %s", async (_label, license) => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => {
|
||||||
|
const changed: Record<string, unknown> = { ...metadata, license };
|
||||||
|
if (license === undefined) {
|
||||||
|
delete changed.license;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed on pre-ADR-0051 metadata", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => {
|
||||||
|
const old: Record<string, unknown> = { ...metadata };
|
||||||
|
delete old.license;
|
||||||
|
return old;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires LICENSE at the canonical path in both payload and inventory", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ tamper: (files) => void files.delete("LICENSE") },
|
||||||
|
"MODULE_PACKAGE_FILE_MISSING",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
files: (metadata.files as { path: string }[]).filter((file) => file.path !== "LICENSE"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
withEntry({ path: "example/licenses/LICENSE", data: "attacker-chosen" }),
|
||||||
|
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
withEntry({ path: "example/COPYING", data: "attacker-chosen" }),
|
||||||
|
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks the listed LICENSE bytes, size, and digest", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ tamper: (files) => files.set("LICENSE", Buffer.from("attacker-chosen")) },
|
||||||
|
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
files: (metadata.files as { path: string; size: number; sha256: string }[]).map((file) =>
|
||||||
|
file.path === "LICENSE" ? { ...file, size: file.size + 1 } : file,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate LICENSE archive entry", async () => {
|
||||||
|
await rejection(
|
||||||
|
withEntry({ path: "example/LICENSE", data: "attacker-chosen" }),
|
||||||
|
"MODULE_PACKAGE_LAYOUT_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("fails when the manifest and the package disagree about the version", async () => {
|
it("fails when the manifest and the package disagree about the version", async () => {
|
||||||
await rejection(
|
await rejection(
|
||||||
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
|
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export async function modulePackageFixture(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
|
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
|
||||||
|
files.set("LICENSE", Buffer.from("Example module license text.\n"));
|
||||||
|
|
||||||
const listed = [...files.entries()]
|
const listed = [...files.entries()]
|
||||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
@@ -169,6 +170,7 @@ export async function modulePackageFixture(
|
|||||||
const baseMetadata: Record<string, unknown> = {
|
const baseMetadata: Record<string, unknown> = {
|
||||||
packageFormat: 1,
|
packageFormat: 1,
|
||||||
module: { id, version },
|
module: { id, version },
|
||||||
|
license: "MIT",
|
||||||
target: "any",
|
target: "any",
|
||||||
runtime: { node: ">=20" },
|
runtime: { node: ">=20" },
|
||||||
provenance: {
|
provenance: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 0051: Module packages declare their own license and carry its text
|
# 0051: Module packages declare their own license and carry its text
|
||||||
|
|
||||||
- Status: Proposed
|
- Status: Accepted
|
||||||
- Date: 2026-10-07
|
- Date: 2026-10-07
|
||||||
- Amends: [0049](0049-module-package-v1.md) — every Module Package v1 artifact gains an
|
- Amends: [0049](0049-module-package-v1.md) — every Module Package v1 artifact gains an
|
||||||
authoritative package-level license declaration and a `LICENSE` payload. `packageFormat` stays 1.
|
authoritative package-level license declaration and a `LICENSE` payload. `packageFormat` stays 1.
|
||||||
|
|||||||
@@ -59,4 +59,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
|
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
|
||||||
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
|
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
|
||||||
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted
|
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted
|
||||||
- [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Proposed
|
- [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Accepted
|
||||||
|
|||||||
@@ -679,7 +679,10 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
|
|||||||
- **The packager.** `@ekklesia/module-pack` bundles the module's backend with every pure-JavaScript
|
- **The packager.** `@ekklesia/module-pack` bundles the module's backend with every pure-JavaScript
|
||||||
dependency, the SDK, contracts, and Drizzle included. It copies the built browser artifacts and
|
dependency, the SDK, contracts, and Drizzle included. It copies the built browser artifacts and
|
||||||
migrations, generates the runtime `package.json`, `ekklesia.package.json` with every file's digest,
|
migrations, generates the runtime `package.json`, `ekklesia.package.json` with every file's digest,
|
||||||
and `THIRD_PARTY_NOTICES`, and writes the archive.
|
copies the explicitly supplied module `LICENSE` bytes, generates `THIRD_PARTY_NOTICES` for bundled
|
||||||
|
third parties, and writes the archive. The source module's string SPDX expression is required and
|
||||||
|
becomes the package metadata's authoritative `license`; the packager neither infers nor downloads
|
||||||
|
license material.
|
||||||
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
|
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
|
||||||
special files, and escaping paths. It checks every digest and identity, the target and runtime,
|
special files, and escaping paths. It checks every digest and identity, the target and runtime,
|
||||||
runs the platform's own module, schema, and migration validation, and loads the backend once in a
|
runs the platform's own module, schema, and migration validation, and loads the backend once in a
|
||||||
|
|||||||
@@ -598,26 +598,31 @@ CI / migration-smoke (pull_request)
|
|||||||
|
|
||||||
Ekklesia Module Package v1 (ADR 0049) is proven at three levels, all in `pnpm verify`:
|
Ekklesia Module Package v1 (ADR 0049) is proven at three levels, all in `pnpm verify`:
|
||||||
|
|
||||||
- `packages/contracts/tests/module-package.test.ts`: the `ekklesia.package.json` schema, target
|
- `packages/contracts/tests/module-package.test.ts`: the `ekklesia.package.json` schema, required
|
||||||
names, and the generated runtime `package.json`.
|
SPDX expression and `LICENSE` inventory entry, target names, and the generated runtime
|
||||||
|
`package.json`.
|
||||||
- `core/tests/module-package-verification.test.ts`: the verifier against hand-built archives,
|
- `core/tests/module-package-verification.test.ts`: the verifier against hand-built archives,
|
||||||
including what the packager never produces. A valid package verifies and extracts with normalized
|
including what the packager never produces. A valid package verifies and extracts with normalized
|
||||||
modes. A wrong digest, a corrupted or missing or unlisted file, disagreeing identities, an unknown
|
modes. A wrong digest, a corrupted or missing or unlisted file, disagreeing identities, an unknown
|
||||||
format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or legacy
|
format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or legacy
|
||||||
database runtime, ABI metadata carrying a forbidden transitional runtime, malformed ABI schema
|
database runtime, ABI metadata carrying a forbidden transitional runtime, malformed ABI schema
|
||||||
metadata, a missing entrypoint, a backend that imports something the package lacks, absolute and
|
metadata, missing or malformed license metadata, missing, unlisted, duplicated, or corrupted
|
||||||
|
`LICENSE`, a missing entrypoint, a backend that imports something the package lacks, absolute and
|
||||||
`..` paths, symbolic and hard links, device nodes and FIFOs, setuid and setgid entries, a second
|
`..` paths, symbolic and hard links, device nodes and FIFOs, setuid and setgid entries, a second
|
||||||
top-level directory, and a duplicate entry each fail with their own sanitized code. The backend
|
top-level directory, and a duplicate entry each fail with their own sanitized code. The backend
|
||||||
load check runs in a real child process and selects the schema contract from the manifest.
|
load check runs in a real child process and selects the schema contract from the manifest.
|
||||||
- `packages/module-pack/tests/`: the packager against generated fixture modules (payload contents,
|
- `packages/module-pack/tests/`: the packager against generated fixture modules (payload contents,
|
||||||
generated `package.json`, metadata, bundled backend with only built-in imports, refusal of native
|
generated `package.json`, metadata, bundled backend with only built-in imports, refusal of native
|
||||||
and platform-specific dependencies and of unknown or textless licences, notices, byte-identical
|
and platform-specific dependencies and of unknown or textless third-party licences, explicit
|
||||||
archives, normalized tar and gzip headers), and end to end against the built module template:
|
module SPDX and license-file validation, unchanged `LICENSE` bytes and inventory digest, notices,
|
||||||
|
byte-identical archives, normalized tar and gzip headers), and end to end against the built module template:
|
||||||
packed twice to identical bytes without changing the workspace's install state, verified by Core's
|
packed twice to identical bytes without changing the workspace's install state, verified by Core's
|
||||||
verifier with a deliberately different Core Drizzle version, extracted elsewhere, and loaded with
|
verifier with a deliberately different Core Drizzle version, extracted elsewhere, and loaded with
|
||||||
Node's own resolution hooks proving nothing outside the package is resolved. The package contains
|
Node's own resolution hooks proving nothing outside the package is resolved. The package contains
|
||||||
module-side Drizzle but no `transitional.databaseRuntime`. Turbo builds the template and Core
|
the repository's exact AGPL `LICENSE`, authoritative `AGPL-3.0-or-later` metadata, module-side
|
||||||
first, and runs the template's own tests (which rebuild its UI in place) before these.
|
Drizzle but no `transitional.databaseRuntime`, and no tooling-only SPDX parser dependency. Turbo
|
||||||
|
builds the template and Core first, and runs the template's own tests (which rebuild its UI in
|
||||||
|
place) before these.
|
||||||
|
|
||||||
## The generic platform image and the reference deployment
|
## The generic platform image and the reference deployment
|
||||||
|
|
||||||
@@ -636,8 +641,9 @@ These need Docker and build images locally; nothing is pushed or published.
|
|||||||
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the reference deployment end to end on the
|
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the reference deployment end to end on the
|
||||||
generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also
|
generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also
|
||||||
runs the installed-module lifecycle:
|
runs the installed-module lifecycle:
|
||||||
- the module template is packaged by the canonical packager (Module Package v1, ADR 0049),
|
- the module template is packaged by the canonical packager (Module Package v1, ADR 0049 and
|
||||||
as ABI v1, verified by the image's own verifier, and installed into module storage;
|
ADR 0051), with its explicit AGPL declaration and license bytes, as ABI v1, verified by the
|
||||||
|
image's own verifier, and installed into module storage;
|
||||||
- its migration is planned and explicitly applied, and the API restarted;
|
- its migration is planned and explicitly applied, and the API restarted;
|
||||||
- a real browser mounts it;
|
- a real browser mounts it;
|
||||||
- it is removed and the API restarted, and Core still runs, migrations still plan and apply, and
|
- it is removed and the API restarted, and Core still runs, migrations still plan and apply, and
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ development dependency like `@ekklesia/module-ui-build`:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm exec ekklesia-pack-module --module . --target any --out ../packages \
|
pnpm exec ekklesia-pack-module --module . --target any --out ../packages \
|
||||||
|
--license-file ./LICENSE \
|
||||||
--source-revision "$(git rev-parse HEAD)"
|
--source-revision "$(git rev-parse HEAD)"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -110,7 +111,8 @@ The archive holds one directory named with your module id:
|
|||||||
<id>/
|
<id>/
|
||||||
├── package.json generated: name, version, private, "type": "module", the layer marker
|
├── package.json generated: name, version, private, "type": "module", the layer marker
|
||||||
├── ekklesia.module.json your manifest, byte for byte
|
├── ekklesia.module.json your manifest, byte for byte
|
||||||
├── ekklesia.package.json package metadata: target, runtime, provenance, file digests
|
├── ekklesia.package.json package metadata: license, target, runtime, provenance, file digests
|
||||||
|
├── LICENSE your explicitly supplied module license material, byte for byte
|
||||||
├── THIRD_PARTY_NOTICES licence and notice text of every third-party package bundled
|
├── THIRD_PARTY_NOTICES licence and notice text of every third-party package bundled
|
||||||
├── dist/server/index.js your backend entrypoints, bundled
|
├── dist/server/index.js your backend entrypoints, bundled
|
||||||
├── dist/schema/index.js
|
├── dist/schema/index.js
|
||||||
@@ -130,11 +132,18 @@ The archive holds one directory named with your module id:
|
|||||||
- **`any` is the only target today.** A dependency that is native or platform-specific (a compiled
|
- **`any` is the only target today.** A dependency that is native or platform-specific (a compiled
|
||||||
addon, a package that declares an operating system or CPU, or one that loads a per-platform binary
|
addon, a package that declares an operating system or CPU, or one that loads a per-platform binary
|
||||||
such as `sharp`) cannot be in an `any` package, and packaging fails. Native targets come later.
|
such as `sharp`) cannot be in an `any` package, and packaging fails. Native targets come later.
|
||||||
- **Licences.** `THIRD_PARTY_NOTICES` credits every third-party package your bundles contain, with
|
- **Your module's licence.** Your source `package.json` must contain a non-empty string SPDX
|
||||||
its declared licence and the licence and notice files it ships. A package that declares no licence
|
expression. The packager validates its syntax, rejects `UNLICENSED`, and copies it unchanged to
|
||||||
fails packaging, and so does one that ships no licence text, unless its licence's standard text is
|
the required `ekklesia.package.json.license`. `--license-file` is also required: it names the
|
||||||
complete without a copyright line (Apache-2.0, which the packager then supplies and says so). The
|
regular file whose exact bytes become the root `LICENSE` payload and inventory entry. The
|
||||||
Ekklesia platform's own packages are listed in `ekklesia.package.json` under `incorporates`.
|
packager never searches parent directories, guesses text from the expression, or downloads it.
|
||||||
|
- **Third-party licences.** `THIRD_PARTY_NOTICES` separately credits every third-party package your
|
||||||
|
bundles contain, with its declared licence and the licence and notice files it ships. A dependency
|
||||||
|
that declares no licence fails packaging, and so does one that ships no licence text, unless its
|
||||||
|
licence's standard text is complete without a copyright line (Apache-2.0, which the packager then
|
||||||
|
supplies and says so). The module itself is not added to this notice file. Bundled Ekklesia
|
||||||
|
packages remain listed in `ekklesia.package.json` under `incorporates`; one applicable AGPL text
|
||||||
|
is sufficient for current official modules.
|
||||||
- **Deterministic.** The same packager version and the same built inputs produce the same archive,
|
- **Deterministic.** The same packager version and the same built inputs produce the same archive,
|
||||||
byte for byte, wherever the checkout is.
|
byte for byte, wherever the checkout is.
|
||||||
|
|
||||||
@@ -167,10 +176,13 @@ later version, `AGPL-3.0-or-later` ([LICENSE](../../LICENSE)), and official Ekkl
|
|||||||
same license. Declare it in your module's development `package.json`
|
same license. Declare it in your module's development `package.json`
|
||||||
(`"license": "AGPL-3.0-or-later"`), as the module template does.
|
(`"license": "AGPL-3.0-or-later"`), as the module template does.
|
||||||
|
|
||||||
A Module Package v1 does not yet record a module's own license: its runtime `package.json` and
|
A Module Package v1 records that validated expression in its required
|
||||||
`ekklesia.package.json` have fixed fields, and its contents are fixed, by ADR 0049. Carrying the
|
`ekklesia.package.json.license` and contains the exact explicitly supplied bytes at `LICENSE`
|
||||||
module's license, and the license text for the Ekklesia code bundled into its backend, needs a
|
([ADR 0051](../architecture/decisions/0051-module-package-license-material.md)). The generated
|
||||||
change to that format.
|
runtime `package.json` is not authoritative for licensing. `THIRD_PARTY_NOTICES` remains only for
|
||||||
|
redistributed third-party dependencies, while bundled Ekklesia components remain represented by
|
||||||
|
`incorporates`. This strict-reader addition keeps `packageFormat` at `1`; older pre-0051 artifacts
|
||||||
|
without the field and payload fail closed.
|
||||||
|
|
||||||
### Removing a module keeps its data
|
### Removing a module keeps its data
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./module-package": {
|
||||||
|
"types": "./dist/module-package.d.ts",
|
||||||
|
"import": "./dist/module-package.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -22,12 +26,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"semver": "7.8.5",
|
"semver": "7.8.5",
|
||||||
|
"spdx-expression-parse": "5.0.0",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
|
"@types/spdx-expression-parse": "4.0.0",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export * from "./module-http.js";
|
|||||||
export * from "./module-lifecycle.js";
|
export * from "./module-lifecycle.js";
|
||||||
export * from "./module-manifest.js";
|
export * from "./module-manifest.js";
|
||||||
export * from "./module-migration.js";
|
export * from "./module-migration.js";
|
||||||
export * from "./module-package.js";
|
|
||||||
export * from "./module-realtime.js";
|
export * from "./module-realtime.js";
|
||||||
export * from "./module-runtime.js";
|
export * from "./module-runtime.js";
|
||||||
export * from "./module-schema.js";
|
export * from "./module-schema.js";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import parseSpdxExpression from "spdx-expression-parse";
|
||||||
import { moduleIdSchema } from "./identifiers.js";
|
import { moduleIdSchema } from "./identifiers.js";
|
||||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
@@ -11,11 +12,30 @@ import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const MODULE_PACKAGE_FORMAT = 1;
|
export const MODULE_PACKAGE_FORMAT = 1;
|
||||||
|
export const MODULE_PACKAGE_LICENSE_FILENAME = "LICENSE";
|
||||||
export const MODULE_PACKAGE_METADATA_FILENAME = "ekklesia.package.json";
|
export const MODULE_PACKAGE_METADATA_FILENAME = "ekklesia.package.json";
|
||||||
export const MODULE_PACKAGE_NOTICES_FILENAME = "THIRD_PARTY_NOTICES";
|
export const MODULE_PACKAGE_NOTICES_FILENAME = "THIRD_PARTY_NOTICES";
|
||||||
/** The portable target: a package with no native or platform-specific file. */
|
/** The portable target: a package with no native or platform-specific file. */
|
||||||
export const MODULE_PACKAGE_ANY_TARGET = "any";
|
export const MODULE_PACKAGE_ANY_TARGET = "any";
|
||||||
|
|
||||||
|
/** The module publisher's package-level license declaration (ADR 0051). This validates SPDX syntax,
|
||||||
|
* not whether Ekklesia approves, recommends, or considers the license legally compatible. */
|
||||||
|
export const modulePackageLicenseExpressionSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(1024)
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
if (value === "UNLICENSED") {
|
||||||
|
context.addIssue({ code: "custom", message: "Must be an SPDX license expression." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parseSpdxExpression(value);
|
||||||
|
} catch {
|
||||||
|
context.addIssue({ code: "custom", message: "Must be an SPDX license expression." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `any`, or `<platform>-<arch>[-<libc>]` in Node.js's own `process.platform` and `process.arch`
|
* `any`, or `<platform>-<arch>[-<libc>]` in Node.js's own `process.platform` and `process.arch`
|
||||||
* vocabulary. On Linux the C library is required, because a native addon built against one does
|
* vocabulary. On Linux the C library is required, because a native addon built against one does
|
||||||
@@ -104,6 +124,8 @@ export const modulePackageMetadataSchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
packageFormat: z.literal(MODULE_PACKAGE_FORMAT),
|
packageFormat: z.literal(MODULE_PACKAGE_FORMAT),
|
||||||
module: z.strictObject({ id: moduleIdSchema, version: semanticVersionSchema }),
|
module: z.strictObject({ id: moduleIdSchema, version: semanticVersionSchema }),
|
||||||
|
/** The module's authoritative package-release license declaration (ADR 0051). */
|
||||||
|
license: modulePackageLicenseExpressionSchema,
|
||||||
target: modulePackageTargetSchema,
|
target: modulePackageTargetSchema,
|
||||||
runtime: modulePackageRuntimeSchema,
|
runtime: modulePackageRuntimeSchema,
|
||||||
provenance: z.strictObject({
|
provenance: z.strictObject({
|
||||||
@@ -124,6 +146,16 @@ export const modulePackageMetadataSchema = z
|
|||||||
files: z.array(modulePackageFileSchema),
|
files: z.array(modulePackageFileSchema),
|
||||||
})
|
})
|
||||||
.superRefine((metadata, context) => {
|
.superRefine((metadata, context) => {
|
||||||
|
const licenseFiles = metadata.files.filter(
|
||||||
|
(file) => file.path === MODULE_PACKAGE_LICENSE_FILENAME,
|
||||||
|
);
|
||||||
|
if (licenseFiles.length !== 1 || licenseFiles[0]?.size === 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "The payload must contain exactly one non-empty root LICENSE file.",
|
||||||
|
path: ["files"],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
metadata.target === MODULE_PACKAGE_ANY_TARGET &&
|
metadata.target === MODULE_PACKAGE_ANY_TARGET &&
|
||||||
(metadata.runtime.nodeApi !== undefined ||
|
(metadata.runtime.nodeApi !== undefined ||
|
||||||
@@ -157,6 +189,10 @@ export function safeParseModulePackageMetadata(input: unknown) {
|
|||||||
return modulePackageMetadataSchema.safeParse(input);
|
return modulePackageMetadataSchema.safeParse(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function safeParseModulePackageLicenseExpression(input: unknown) {
|
||||||
|
return modulePackageLicenseExpressionSchema.safeParse(input);
|
||||||
|
}
|
||||||
|
|
||||||
export function safeParseModulePackageTarget(input: unknown) {
|
export function safeParseModulePackageTarget(input: unknown) {
|
||||||
return modulePackageTargetSchema.safeParse(input);
|
return modulePackageTargetSchema.safeParse(input);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
MODULE_PACKAGE_FORMAT,
|
MODULE_PACKAGE_FORMAT,
|
||||||
|
MODULE_PACKAGE_LICENSE_FILENAME,
|
||||||
MODULE_PACKAGE_METADATA_FILENAME,
|
MODULE_PACKAGE_METADATA_FILENAME,
|
||||||
modulePackageArchiveFileName,
|
modulePackageArchiveFileName,
|
||||||
modulePackageRuntimePackage,
|
modulePackageRuntimePackage,
|
||||||
|
safeParseModulePackageLicenseExpression,
|
||||||
safeParseModulePackageMetadata,
|
safeParseModulePackageMetadata,
|
||||||
safeParseModulePackageRuntimePackage,
|
safeParseModulePackageRuntimePackage,
|
||||||
safeParseModulePackageTarget,
|
safeParseModulePackageTarget,
|
||||||
} from "../src/index.js";
|
} from "../src/module-package.js";
|
||||||
|
|
||||||
const digest = "a".repeat(64);
|
const digest = "a".repeat(64);
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
|
|||||||
return {
|
return {
|
||||||
packageFormat: 1,
|
packageFormat: 1,
|
||||||
module: { id: "example", version: "0.1.0" },
|
module: { id: "example", version: "0.1.0" },
|
||||||
|
license: "AGPL-3.0-or-later",
|
||||||
target: "any",
|
target: "any",
|
||||||
runtime: { node: ">=24.11.0 <25" },
|
runtime: { node: ">=24.11.0 <25" },
|
||||||
provenance: {
|
provenance: {
|
||||||
@@ -24,6 +27,7 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
|
|||||||
incorporates: { "@ekklesia/contracts": "0.1.2", "@ekklesia/module-sdk": "0.1.2" },
|
incorporates: { "@ekklesia/contracts": "0.1.2", "@ekklesia/module-sdk": "0.1.2" },
|
||||||
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
||||||
files: [
|
files: [
|
||||||
|
{ path: "LICENSE", size: 9, sha256: digest },
|
||||||
{ path: "THIRD_PARTY_NOTICES", size: 10, sha256: digest },
|
{ path: "THIRD_PARTY_NOTICES", size: 10, sha256: digest },
|
||||||
{ path: "dist/server/index.js", size: 20, sha256: digest },
|
{ path: "dist/server/index.js", size: 20, sha256: digest },
|
||||||
{ path: "ekklesia.module.json", size: 30, sha256: digest },
|
{ path: "ekklesia.module.json", size: 30, sha256: digest },
|
||||||
@@ -36,12 +40,46 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
|
|||||||
describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
|
describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
|
||||||
it("names the format, its metadata file, and the recommended archive file name", () => {
|
it("names the format, its metadata file, and the recommended archive file name", () => {
|
||||||
expect(MODULE_PACKAGE_FORMAT).toBe(1);
|
expect(MODULE_PACKAGE_FORMAT).toBe(1);
|
||||||
|
expect(MODULE_PACKAGE_LICENSE_FILENAME).toBe("LICENSE");
|
||||||
expect(MODULE_PACKAGE_METADATA_FILENAME).toBe("ekklesia.package.json");
|
expect(MODULE_PACKAGE_METADATA_FILENAME).toBe("ekklesia.package.json");
|
||||||
expect(modulePackageArchiveFileName("example", "0.1.0", "any")).toBe(
|
expect(modulePackageArchiveFileName("example", "0.1.0", "any")).toBe(
|
||||||
"example-0.1.0-any.tar.gz",
|
"example-0.1.0-any.tar.gz",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts SPDX license expressions and rejects non-expressions without a policy allowlist", () => {
|
||||||
|
for (const expression of [
|
||||||
|
"AGPL-3.0-or-later",
|
||||||
|
"MIT OR Apache-2.0",
|
||||||
|
"(MIT OR Apache-2.0) AND BSD-3-Clause",
|
||||||
|
]) {
|
||||||
|
expect(safeParseModulePackageLicenseExpression(expression).success, expression).toBe(true);
|
||||||
|
}
|
||||||
|
for (const expression of ["", "UNLICENSED", "MIT OR", "not a license", {}]) {
|
||||||
|
expect(safeParseModulePackageLicenseExpression(expression).success, String(expression)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the package license declaration and canonical LICENSE payload entry", () => {
|
||||||
|
const withoutLicense = metadata();
|
||||||
|
delete withoutLicense.license;
|
||||||
|
expect(safeParseModulePackageMetadata(withoutLicense).success).toBe(false);
|
||||||
|
|
||||||
|
const withoutLicenseFile = metadata();
|
||||||
|
withoutLicenseFile.files = (withoutLicenseFile.files as { path: string }[]).filter(
|
||||||
|
(file) => file.path !== "LICENSE",
|
||||||
|
);
|
||||||
|
expect(safeParseModulePackageMetadata(withoutLicenseFile).success).toBe(false);
|
||||||
|
|
||||||
|
const emptyLicenseFile = metadata();
|
||||||
|
emptyLicenseFile.files = (emptyLicenseFile.files as { path: string; size: number }[]).map(
|
||||||
|
(file) => (file.path === "LICENSE" ? { ...file, size: 0 } : file),
|
||||||
|
);
|
||||||
|
expect(safeParseModulePackageMetadata(emptyLicenseFile).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts a complete any-target package description", () => {
|
it("accepts a complete any-target package description", () => {
|
||||||
expect(safeParseModulePackageMetadata(metadata()).success).toBe(true);
|
expect(safeParseModulePackageMetadata(metadata()).success).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -97,7 +135,9 @@ describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
// Byte-wise, not locale order: upper case sorts before lower case.
|
// Byte-wise, not locale order: upper case sorts before lower case.
|
||||||
expect(
|
expect(
|
||||||
safeParseModulePackageMetadata(metadata({ files: [entry("THIRD"), entry("dist")] })).success,
|
safeParseModulePackageMetadata(
|
||||||
|
metadata({ files: [entry("LICENSE"), entry("THIRD"), entry("dist")] }),
|
||||||
|
).success,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ModulePackError, type PackModuleOptions, packModule } from "./pack-modu
|
|||||||
* `ekklesia-pack-module` — the canonical Ekklesia module packager (ADR 0049).
|
* `ekklesia-pack-module` — the canonical Ekklesia module packager (ADR 0049).
|
||||||
*
|
*
|
||||||
* ekklesia-pack-module --module <built module directory> --target any --out <directory>
|
* ekklesia-pack-module --module <built module directory> --target any --out <directory>
|
||||||
* --source-revision <full commit id> [--repository <url>]
|
* --license-file <file> --source-revision <full commit id> [--repository <url>]
|
||||||
*
|
*
|
||||||
* Writes `<id>-<version>-any.tar.gz` and its `.sha256` line to the output directory, and nothing
|
* Writes `<id>-<version>-any.tar.gz` and its `.sha256` line to the output directory, and nothing
|
||||||
* else anywhere. Run it in the module's CI after building the module.
|
* else anywhere. Run it in the module's CI after building the module.
|
||||||
@@ -18,9 +18,10 @@ import { ModulePackError, type PackModuleOptions, packModule } from "./pack-modu
|
|||||||
class PackArgumentsError extends Error {}
|
class PackArgumentsError extends Error {}
|
||||||
|
|
||||||
const USAGE =
|
const USAGE =
|
||||||
"Usage: ekklesia-pack-module --module <directory> --target any --out <directory> --source-revision <commit> [--repository <url>]";
|
"Usage: ekklesia-pack-module --module <directory> --target any --out <directory> --license-file <file> --source-revision <commit> [--repository <url>]";
|
||||||
|
|
||||||
const OPTIONS: Readonly<Record<string, keyof PackModuleOptions>> = Object.freeze({
|
const OPTIONS: Readonly<Record<string, keyof PackModuleOptions>> = Object.freeze({
|
||||||
|
"--license-file": "licenseFile",
|
||||||
"--module": "moduleDirectory",
|
"--module": "moduleDirectory",
|
||||||
"--out": "outDirectory",
|
"--out": "outDirectory",
|
||||||
"--repository": "repository",
|
"--repository": "repository",
|
||||||
@@ -43,12 +44,19 @@ export function parsePackModuleArguments(argv: readonly string[]): PackModuleOpt
|
|||||||
}
|
}
|
||||||
values.set(option, value);
|
values.set(option, value);
|
||||||
}
|
}
|
||||||
const required = ["moduleDirectory", "outDirectory", "sourceRevision", "target"] as const;
|
const required = [
|
||||||
|
"licenseFile",
|
||||||
|
"moduleDirectory",
|
||||||
|
"outDirectory",
|
||||||
|
"sourceRevision",
|
||||||
|
"target",
|
||||||
|
] as const;
|
||||||
if (required.some((option) => !values.has(option))) {
|
if (required.some((option) => !values.has(option))) {
|
||||||
throw new PackArgumentsError(USAGE);
|
throw new PackArgumentsError(USAGE);
|
||||||
}
|
}
|
||||||
const repository = values.get("repository");
|
const repository = values.get("repository");
|
||||||
return {
|
return {
|
||||||
|
licenseFile: values.get("licenseFile") ?? "",
|
||||||
moduleDirectory: values.get("moduleDirectory") ?? "",
|
moduleDirectory: values.get("moduleDirectory") ?? "",
|
||||||
outDirectory: values.get("outDirectory") ?? "",
|
outDirectory: values.get("outDirectory") ?? "",
|
||||||
...(repository === undefined ? {} : { repository }),
|
...(repository === undefined ? {} : { repository }),
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ import { lstat, mkdir, readdir, readFile, realpath, rename, writeFile } from "no
|
|||||||
import { isBuiltin } from "node:module";
|
import { isBuiltin } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { type ModuleManifest, safeParseModuleManifest } from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
MODULE_PACKAGE_ANY_TARGET,
|
MODULE_PACKAGE_ANY_TARGET,
|
||||||
MODULE_PACKAGE_FORMAT,
|
MODULE_PACKAGE_FORMAT,
|
||||||
|
MODULE_PACKAGE_LICENSE_FILENAME,
|
||||||
MODULE_PACKAGE_METADATA_FILENAME,
|
MODULE_PACKAGE_METADATA_FILENAME,
|
||||||
MODULE_PACKAGE_NOTICES_FILENAME,
|
MODULE_PACKAGE_NOTICES_FILENAME,
|
||||||
type ModuleManifest,
|
|
||||||
modulePackageArchiveFileName,
|
modulePackageArchiveFileName,
|
||||||
modulePackageRuntimePackage,
|
modulePackageRuntimePackage,
|
||||||
safeParseModuleManifest,
|
safeParseModulePackageLicenseExpression,
|
||||||
safeParseModulePackageMetadata,
|
safeParseModulePackageMetadata,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts/module-package";
|
||||||
import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build";
|
import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build";
|
||||||
import { build, type Message, type Plugin } from "esbuild";
|
import { build, type Message, type Plugin } from "esbuild";
|
||||||
import { minVersion, satisfies, validRange } from "semver";
|
import { minVersion, satisfies, validRange } from "semver";
|
||||||
@@ -40,6 +41,8 @@ export type ModulePackErrorCode =
|
|||||||
| "MODULE_PACK_IDENTITY_MISMATCH"
|
| "MODULE_PACK_IDENTITY_MISMATCH"
|
||||||
| "MODULE_PACK_INCORPORATED_CONFLICT"
|
| "MODULE_PACK_INCORPORATED_CONFLICT"
|
||||||
| "MODULE_PACK_LAYOUT_CONFLICT"
|
| "MODULE_PACK_LAYOUT_CONFLICT"
|
||||||
|
| "MODULE_PACK_MODULE_LICENSE_FILE_INVALID"
|
||||||
|
| "MODULE_PACK_MODULE_LICENSE_INVALID"
|
||||||
| "MODULE_PACK_LICENCE_TEXT_MISSING"
|
| "MODULE_PACK_LICENCE_TEXT_MISSING"
|
||||||
| "MODULE_PACK_LICENCE_UNKNOWN"
|
| "MODULE_PACK_LICENCE_UNKNOWN"
|
||||||
| "MODULE_PACK_MANIFEST_INVALID"
|
| "MODULE_PACK_MANIFEST_INVALID"
|
||||||
@@ -71,6 +74,10 @@ const messages: Readonly<Record<ModulePackErrorCode, string>> = Object.freeze({
|
|||||||
MODULE_PACK_INCORPORATED_CONFLICT:
|
MODULE_PACK_INCORPORATED_CONFLICT:
|
||||||
"The package bundles more than one version of an Ekklesia package.",
|
"The package bundles more than one version of an Ekklesia package.",
|
||||||
MODULE_PACK_LAYOUT_CONFLICT: "Two package files would have the same path.",
|
MODULE_PACK_LAYOUT_CONFLICT: "Two package files would have the same path.",
|
||||||
|
MODULE_PACK_MODULE_LICENSE_FILE_INVALID:
|
||||||
|
"The module license text must be supplied as an explicit, non-empty, readable regular file.",
|
||||||
|
MODULE_PACK_MODULE_LICENSE_INVALID:
|
||||||
|
"The source package.json must declare a valid SPDX license expression as a string.",
|
||||||
MODULE_PACK_LICENCE_TEXT_MISSING:
|
MODULE_PACK_LICENCE_TEXT_MISSING:
|
||||||
"A bundled package ships no licence text, and its licence has no complete standard text to supply.",
|
"A bundled package ships no licence text, and its licence has no complete standard text to supply.",
|
||||||
MODULE_PACK_LICENCE_UNKNOWN: "A bundled package declares no licence that permits redistribution.",
|
MODULE_PACK_LICENCE_UNKNOWN: "A bundled package declares no licence that permits redistribution.",
|
||||||
@@ -115,6 +122,8 @@ export class ModulePackError extends Error {
|
|||||||
export interface PackModuleOptions {
|
export interface PackModuleOptions {
|
||||||
/** A built module directory: its manifest, package.json, and compiled entrypoints. */
|
/** A built module directory: its manifest, package.json, and compiled entrypoints. */
|
||||||
readonly moduleDirectory: string;
|
readonly moduleDirectory: string;
|
||||||
|
/** Explicit license-text input. Its bytes are copied unchanged to the package root as LICENSE. */
|
||||||
|
readonly licenseFile: string;
|
||||||
/** `any` only, in this release. */
|
/** `any` only, in this release. */
|
||||||
readonly target: string;
|
readonly target: string;
|
||||||
readonly outDirectory: string;
|
readonly outDirectory: string;
|
||||||
@@ -377,6 +386,28 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
|
|||||||
) {
|
) {
|
||||||
throw new ModulePackError("MODULE_PACK_IDENTITY_MISMATCH");
|
throw new ModulePackError("MODULE_PACK_IDENTITY_MISMATCH");
|
||||||
}
|
}
|
||||||
|
const parsedLicense = safeParseModulePackageLicenseExpression(developmentPackage.license);
|
||||||
|
if (!parsedLicense.success) {
|
||||||
|
throw new ModulePackError("MODULE_PACK_MODULE_LICENSE_INVALID");
|
||||||
|
}
|
||||||
|
const license = parsedLicense.data;
|
||||||
|
let licenseBytes: Buffer;
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
typeof options.licenseFile !== "string" ||
|
||||||
|
options.licenseFile === "" ||
|
||||||
|
!(await lstat(path.resolve(options.licenseFile))).isFile()
|
||||||
|
) {
|
||||||
|
throw new Error("not a regular file");
|
||||||
|
}
|
||||||
|
licenseBytes = await readFile(path.resolve(options.licenseFile));
|
||||||
|
if (licenseBytes.length === 0) {
|
||||||
|
// An empty file would satisfy the inventory while carrying no license material at all.
|
||||||
|
throw new Error("empty");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new ModulePackError("MODULE_PACK_MODULE_LICENSE_FILE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
const nodeRange = options.nodeRange ?? (await workspaceNodeRange(moduleDirectory));
|
const nodeRange = options.nodeRange ?? (await workspaceNodeRange(moduleDirectory));
|
||||||
if (validRange(nodeRange) === null) {
|
if (validRange(nodeRange) === null) {
|
||||||
@@ -493,6 +524,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
add("ekklesia.module.json", manifestBytes);
|
add("ekklesia.module.json", manifestBytes);
|
||||||
|
add(MODULE_PACKAGE_LICENSE_FILENAME, licenseBytes);
|
||||||
add(
|
add(
|
||||||
"package.json",
|
"package.json",
|
||||||
Buffer.from(
|
Buffer.from(
|
||||||
@@ -522,6 +554,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
|
|||||||
const metadata = {
|
const metadata = {
|
||||||
packageFormat: MODULE_PACKAGE_FORMAT,
|
packageFormat: MODULE_PACKAGE_FORMAT,
|
||||||
module: { id: manifest.id, version: manifest.version },
|
module: { id: manifest.id, version: manifest.version },
|
||||||
|
license,
|
||||||
target: MODULE_PACKAGE_ANY_TARGET,
|
target: MODULE_PACKAGE_ANY_TARGET,
|
||||||
runtime: { node: nodeRange },
|
runtime: { node: nodeRange },
|
||||||
provenance: {
|
provenance: {
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ describe("ekklesia-pack-module arguments", () => {
|
|||||||
"any",
|
"any",
|
||||||
"--out",
|
"--out",
|
||||||
"dist/packages",
|
"dist/packages",
|
||||||
|
"--license-file",
|
||||||
|
"LICENSE",
|
||||||
"--source-revision",
|
"--source-revision",
|
||||||
revision,
|
revision,
|
||||||
]),
|
]),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
moduleDirectory: "templates/module",
|
moduleDirectory: "templates/module",
|
||||||
|
licenseFile: "LICENSE",
|
||||||
outDirectory: "dist/packages",
|
outDirectory: "dist/packages",
|
||||||
sourceRevision: revision,
|
sourceRevision: revision,
|
||||||
target: "any",
|
target: "any",
|
||||||
@@ -33,6 +36,8 @@ describe("ekklesia-pack-module arguments", () => {
|
|||||||
"any",
|
"any",
|
||||||
"--out",
|
"--out",
|
||||||
"out",
|
"out",
|
||||||
|
"--license-file",
|
||||||
|
"LICENSE",
|
||||||
"--source-revision",
|
"--source-revision",
|
||||||
revision,
|
revision,
|
||||||
"--repository",
|
"--repository",
|
||||||
@@ -44,6 +49,10 @@ describe("ekklesia-pack-module arguments", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["no arguments", []],
|
["no arguments", []],
|
||||||
["a missing target", ["--module", ".", "--out", "o", "--source-revision", revision]],
|
["a missing target", ["--module", ".", "--out", "o", "--source-revision", revision]],
|
||||||
|
[
|
||||||
|
"a missing license file",
|
||||||
|
["--module", ".", "--target", "any", "--out", "o", "--source-revision", revision],
|
||||||
|
],
|
||||||
["a missing revision", ["--module", ".", "--target", "any", "--out", "o"]],
|
["a missing revision", ["--module", ".", "--target", "any", "--out", "o"]],
|
||||||
[
|
[
|
||||||
"an unknown option",
|
"an unknown option",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile, symlink, writeFile } from "node:fs/promises";
|
||||||
import { isBuiltin } from "node:module";
|
import { isBuiltin } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
@@ -21,6 +21,7 @@ async function pack(options: FixtureModuleOptions = {}, overrides: Record<string
|
|||||||
const moduleDirectory = await fixtureModule(options);
|
const moduleDirectory = await fixtureModule(options);
|
||||||
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
|
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
|
||||||
const result = await packModule({
|
const result = await packModule({
|
||||||
|
licenseFile: path.join(moduleDirectory, "LICENSE"),
|
||||||
moduleDirectory,
|
moduleDirectory,
|
||||||
nodeRange: ">=24.11.0 <25",
|
nodeRange: ">=24.11.0 <25",
|
||||||
outDirectory,
|
outDirectory,
|
||||||
@@ -61,6 +62,7 @@ describe("packModule: what a package contains (ADR 0049)", () => {
|
|||||||
const { archive } = await pack({ withSchema: true });
|
const { archive } = await pack({ withSchema: true });
|
||||||
const files = [...fileEntries(archive).keys()].sort();
|
const files = [...fileEntries(archive).keys()].sort();
|
||||||
expect(files).toEqual([
|
expect(files).toEqual([
|
||||||
|
"example/LICENSE",
|
||||||
"example/THIRD_PARTY_NOTICES",
|
"example/THIRD_PARTY_NOTICES",
|
||||||
expect.stringMatching(/^example\/dist\/chunks\/chunk-[A-Z0-9]+\.js$/u),
|
expect.stringMatching(/^example\/dist\/chunks\/chunk-[A-Z0-9]+\.js$/u),
|
||||||
"example/dist/schema/index.js",
|
"example/dist/schema/index.js",
|
||||||
@@ -81,6 +83,62 @@ describe("packModule: what a package contains (ADR 0049)", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("copies the explicit license bytes unchanged and records their source SPDX expression", async () => {
|
||||||
|
const licenseText = Buffer.from([0x4d, 0x49, 0x54, 0x0d, 0x0a, 0x00, 0xff]);
|
||||||
|
const { archive } = await pack({ license: "MIT OR Apache-2.0", licenseText });
|
||||||
|
const files = fileEntries(archive);
|
||||||
|
const packaged = files.get("example/LICENSE");
|
||||||
|
const metadata = JSON.parse(
|
||||||
|
files.get("example/ekklesia.package.json")?.toString("utf8") ?? "{}",
|
||||||
|
) as {
|
||||||
|
license?: string;
|
||||||
|
files?: { path: string; size: number; sha256: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(metadata.license).toBe("MIT OR Apache-2.0");
|
||||||
|
expect(packaged?.equals(licenseText)).toBe(true);
|
||||||
|
expect(metadata.files?.filter((file) => file.path === "LICENSE")).toEqual([
|
||||||
|
{
|
||||||
|
path: "LICENSE",
|
||||||
|
size: licenseText.length,
|
||||||
|
sha256: createHash("sha256").update(licenseText).digest("hex"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a missing declaration", undefined],
|
||||||
|
["an empty declaration", ""],
|
||||||
|
["UNLICENSED", "UNLICENSED"],
|
||||||
|
["legacy object metadata", { type: "MIT", url: "https://example.invalid/license" }],
|
||||||
|
["a malformed SPDX expression", "MIT OR"],
|
||||||
|
])("refuses %s", async (_label, license) => {
|
||||||
|
await packRejection({ license }, "MODULE_PACK_MODULE_LICENSE_INVALID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a readable regular license-text input", async () => {
|
||||||
|
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
|
||||||
|
licenseFile: undefined,
|
||||||
|
});
|
||||||
|
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
|
||||||
|
licenseFile: path.join(await scratchDirectory("ekklesia-missing-license-"), "missing"),
|
||||||
|
});
|
||||||
|
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
|
||||||
|
licenseFile: await scratchDirectory("ekklesia-license-directory-"),
|
||||||
|
});
|
||||||
|
const emptyDirectory = await scratchDirectory("ekklesia-empty-license-");
|
||||||
|
await writeFile(path.join(emptyDirectory, "LICENSE"), "");
|
||||||
|
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
|
||||||
|
licenseFile: path.join(emptyDirectory, "LICENSE"),
|
||||||
|
});
|
||||||
|
const linkDirectory = await scratchDirectory("ekklesia-linked-license-");
|
||||||
|
await writeFile(path.join(linkDirectory, "real"), "MIT License\n");
|
||||||
|
await symlink(path.join(linkDirectory, "real"), path.join(linkDirectory, "LICENSE"));
|
||||||
|
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
|
||||||
|
licenseFile: path.join(linkDirectory, "LICENSE"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("generates the runtime package.json instead of copying the development one", async () => {
|
it("generates the runtime package.json instead of copying the development one", async () => {
|
||||||
const { archive } = await pack();
|
const { archive } = await pack();
|
||||||
const runtimePackage = fileEntries(archive).get("example/package.json")?.toString("utf8");
|
const runtimePackage = fileEntries(archive).get("example/package.json")?.toString("utf8");
|
||||||
@@ -420,11 +478,12 @@ describe("packModule: THIRD_PARTY_NOTICES", () => {
|
|||||||
|
|
||||||
describe("packModule: deterministic archives", () => {
|
describe("packModule: deterministic archives", () => {
|
||||||
it("produces byte-identical archives and digests for identical inputs", async () => {
|
it("produces byte-identical archives and digests for identical inputs", async () => {
|
||||||
const moduleDirectory = await fixtureModule({ withSchema: true });
|
|
||||||
const runs = [];
|
const runs = [];
|
||||||
for (let run = 0; run < 2; run += 1) {
|
for (let run = 0; run < 2; run += 1) {
|
||||||
|
const moduleDirectory = await fixtureModule({ withSchema: true });
|
||||||
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
|
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
|
||||||
const result = await packModule({
|
const result = await packModule({
|
||||||
|
licenseFile: path.join(moduleDirectory, "LICENSE"),
|
||||||
moduleDirectory,
|
moduleDirectory,
|
||||||
nodeRange: ">=24.11.0 <25",
|
nodeRange: ">=24.11.0 <25",
|
||||||
outDirectory,
|
outDirectory,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface FakePackage {
|
|||||||
export interface FixtureModuleOptions {
|
export interface FixtureModuleOptions {
|
||||||
readonly databaseAbi?: 1;
|
readonly databaseAbi?: 1;
|
||||||
readonly id?: string;
|
readonly id?: string;
|
||||||
|
readonly license?: unknown;
|
||||||
|
readonly licenseText?: string | Uint8Array;
|
||||||
readonly version?: string;
|
readonly version?: string;
|
||||||
readonly serverSource?: string;
|
readonly serverSource?: string;
|
||||||
readonly withModuleDatabase?: boolean;
|
readonly withModuleDatabase?: boolean;
|
||||||
@@ -114,6 +116,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
|
|||||||
{
|
{
|
||||||
name: `@test/module-${id}`,
|
name: `@test/module-${id}`,
|
||||||
version,
|
version,
|
||||||
|
license: Object.hasOwn(options, "license") ? options.license : "MIT",
|
||||||
private: true,
|
private: true,
|
||||||
type: "module",
|
type: "module",
|
||||||
scripts: { build: "tsc" },
|
scripts: { build: "tsc" },
|
||||||
@@ -130,6 +133,10 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
|
|||||||
2,
|
2,
|
||||||
)}\n`,
|
)}\n`,
|
||||||
);
|
);
|
||||||
|
await write(
|
||||||
|
path.join(moduleDirectory, "LICENSE"),
|
||||||
|
options.licenseText ?? "MIT License\n\nCopyright (c) 2026 Example Module\n",
|
||||||
|
);
|
||||||
await write(
|
await write(
|
||||||
path.join(moduleDirectory, "dist/server/index.js"),
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
options.serverSource ?? defaultServer(id, version, options.databaseAbi),
|
options.serverSource ?? defaultServer(id, version, options.databaseAbi),
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ async function treeListing(directory: string): Promise<readonly string[]> {
|
|||||||
async function packTemplate() {
|
async function packTemplate() {
|
||||||
const outDirectory = await scratchDirectory("ekklesia-template-package-");
|
const outDirectory = await scratchDirectory("ekklesia-template-package-");
|
||||||
const result = await packModule({
|
const result = await packModule({
|
||||||
|
licenseFile: path.join(repositoryRoot, "LICENSE"),
|
||||||
moduleDirectory: await templateDirectory(),
|
moduleDirectory: await templateDirectory(),
|
||||||
outDirectory,
|
outDirectory,
|
||||||
sourceRevision: SOURCE_REVISION,
|
sourceRevision: SOURCE_REVISION,
|
||||||
@@ -104,8 +105,19 @@ describe("the module template as a Module Package v1", () => {
|
|||||||
const files = fileEntries(archive);
|
const files = fileEntries(archive);
|
||||||
const manifest = JSON.parse(files.get("example/ekklesia.module.json")?.toString("utf8") ?? "");
|
const manifest = JSON.parse(files.get("example/ekklesia.module.json")?.toString("utf8") ?? "");
|
||||||
const metadata = JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? "");
|
const metadata = JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? "");
|
||||||
|
const canonicalLicense = await readFile(path.join(repositoryRoot, "LICENSE"));
|
||||||
|
const packagedLicense = files.get("example/LICENSE");
|
||||||
|
|
||||||
expect(manifest.database).toEqual({ abi: 1 });
|
expect(manifest.database).toEqual({ abi: 1 });
|
||||||
|
expect(metadata.license).toBe("AGPL-3.0-or-later");
|
||||||
|
expect(packagedLicense).toEqual(canonicalLicense);
|
||||||
|
expect(metadata.files.filter((file: { path: string }) => file.path === "LICENSE")).toEqual([
|
||||||
|
{
|
||||||
|
path: "LICENSE",
|
||||||
|
size: canonicalLicense.length,
|
||||||
|
sha256: createHash("sha256").update(canonicalLicense).digest("hex"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(metadata.transitional).toBeUndefined();
|
expect(metadata.transitional).toBeUndefined();
|
||||||
expect(metadata.incorporates).toMatchObject({
|
expect(metadata.incorporates).toMatchObject({
|
||||||
"@ekklesia/module-database": expect.any(String),
|
"@ekklesia/module-database": expect.any(String),
|
||||||
@@ -209,6 +221,12 @@ describe("the module template as a Module Package v1", () => {
|
|||||||
new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&")} \\S+$`, "mu"),
|
new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&")} \\S+$`, "mu"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
expect(notices).not.toContain("@ekklesia/module-template");
|
||||||
|
expect(notices).not.toMatch(/^@ekklesia\//mu);
|
||||||
|
expect(bundled).not.toContain("spdx-expression-parse");
|
||||||
|
expect(bundled).not.toContain("spdx-exceptions");
|
||||||
|
expect(notices).not.toContain("spdx-expression-parse");
|
||||||
|
expect(notices).not.toContain("spdx-exceptions");
|
||||||
const licence = await readFile(path.join(packageRoot, "licenses", "Apache-2.0.txt"));
|
const licence = await readFile(path.join(packageRoot, "licenses", "Apache-2.0.txt"));
|
||||||
expect(createHash("sha256").update(licence).digest("hex")).toBe(
|
expect(createHash("sha256").update(licence).digest("hex")).toBe(
|
||||||
"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
|
"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
|
||||||
|
|||||||
Generated
+29
@@ -155,6 +155,9 @@ importers:
|
|||||||
semver:
|
semver:
|
||||||
specifier: 7.8.5
|
specifier: 7.8.5
|
||||||
version: 7.8.5
|
version: 7.8.5
|
||||||
|
spdx-expression-parse:
|
||||||
|
specifier: 5.0.0
|
||||||
|
version: 5.0.0
|
||||||
zod:
|
zod:
|
||||||
specifier: 4.4.3
|
specifier: 4.4.3
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
@@ -168,6 +171,9 @@ importers:
|
|||||||
'@types/semver':
|
'@types/semver':
|
||||||
specifier: 7.7.1
|
specifier: 7.7.1
|
||||||
version: 7.7.1
|
version: 7.7.1
|
||||||
|
'@types/spdx-expression-parse':
|
||||||
|
specifier: 4.0.0
|
||||||
|
version: 4.0.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 7.0.2
|
specifier: 7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
@@ -1457,6 +1463,9 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==}
|
resolution: {integrity: sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||||
|
|
||||||
@@ -2256,6 +2265,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
engines: {node: '>= 10.x'}
|
||||||
@@ -3325,6 +3343,8 @@ snapshots:
|
|||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
'@types/nodemailer': 8.0.1
|
'@types/nodemailer': 8.0.1
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@typescript/[email protected]':
|
'@typescript/[email protected]':
|
||||||
@@ -3962,6 +3982,15 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
spdx-exceptions: 2.5.0
|
||||||
|
spdx-license-ids: 3.0.24
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ RUN pnpm exec turbo run build --filter=@ekklesia/module-template --filter=@ekkle
|
|||||||
ARG SOURCE_REVISION
|
ARG SOURCE_REVISION
|
||||||
RUN test -n "$SOURCE_REVISION" && \
|
RUN test -n "$SOURCE_REVISION" && \
|
||||||
node packages/module-pack/bin/ekklesia-pack-module.mjs --module templates/module --target any \
|
node packages/module-pack/bin/ekklesia-pack-module.mjs --module templates/module --target any \
|
||||||
--out /package --source-revision "$SOURCE_REVISION"
|
--out /package --license-file LICENSE --source-revision "$SOURCE_REVISION"
|
||||||
|
|
||||||
FROM scratch AS package
|
FROM scratch AS package
|
||||||
COPY --from=build /package/ /
|
COPY --from=build /package/ /
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ bundles the backend into one deterministic Ekklesia Module Package (ADR 0049):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm modules:pack --module templates/module --target any --out <directory> \
|
pnpm modules:pack --module templates/module --target any --out <directory> \
|
||||||
|
--license-file LICENSE \
|
||||||
--source-revision "$(git rev-parse HEAD)"
|
--source-revision "$(git rev-parse HEAD)"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,4 +90,9 @@ classification or approval.
|
|||||||
The template's `package.json` declares `"license": "AGPL-3.0-or-later"`, the license of Ekklesia
|
The template's `package.json` declares `"license": "AGPL-3.0-or-later"`, the license of Ekklesia
|
||||||
and of official Ekklesia modules (see the repository [README](../../README.md#license) and
|
and of official Ekklesia modules (see the repository [README](../../README.md#license) and
|
||||||
[LICENSE](../../LICENSE)). A module started from the template begins with that declaration; an
|
[LICENSE](../../LICENSE)). A module started from the template begins with that declaration; an
|
||||||
official Ekklesia module keeps it.
|
official Ekklesia module keeps it. The packaging command explicitly supplies the repository's
|
||||||
|
canonical `LICENSE`, avoiding a duplicate file in this nested template. Its exact bytes become the
|
||||||
|
package-root `LICENSE`, while `ekklesia.package.json.license` receives the source declaration.
|
||||||
|
Standalone modules should keep their own license file and pass its path explicitly. The packager
|
||||||
|
does not infer or download license material, and `THIRD_PARTY_NOTICES` remains only for bundled
|
||||||
|
third-party dependencies.
|
||||||
|
|||||||
Reference in New Issue
Block a user