Compare commits
4
Commits
22404ea2d4
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1788d17f0a | ||
|
|
1caffc5f26 | ||
|
|
debb8df3ce | ||
|
|
98e59d4ba0 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -221,9 +221,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: emptyModulesDirectory,
|
modulesDirectory: emptyModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
||||||
@@ -339,7 +339,7 @@ async function createGatedRouteModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 8,
|
manifestVersion: 8,
|
||||||
@@ -390,7 +390,7 @@ async function createFixtureModule(
|
|||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
permissions: [],
|
permissions: [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
...manifestOverrides,
|
...manifestOverrides,
|
||||||
@@ -521,7 +521,7 @@ async function createUiModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 8,
|
manifestVersion: 8,
|
||||||
@@ -2592,9 +2592,9 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
const report = await diagnoseEkklesia({
|
const report = await diagnoseEkklesia({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
runtimeRange: ">=0.0.0",
|
runtimeRange: ">=0.0.0",
|
||||||
runtimeVersion: process.versions.node,
|
runtimeVersion: process.versions.node,
|
||||||
@@ -3906,9 +3906,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ async function createFixtureModule(
|
|||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
permissions: [],
|
permissions: [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
...manifestOverrides,
|
...manifestOverrides,
|
||||||
@@ -267,7 +267,7 @@ function fakeDiscoveredModule(
|
|||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 8,
|
manifestVersion: 8,
|
||||||
@@ -1435,9 +1435,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
||||||
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
});
|
});
|
||||||
mockPoolAndAuth();
|
mockPoolAndAuth();
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
|
|||||||
@@ -212,9 +212,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: emptyModulesDirectory,
|
modulesDirectory: emptyModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
@@ -241,9 +241,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: fixtureModulesDirectory,
|
modulesDirectory: fixtureModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.2.0"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function discoveryOptions(modulesDirectory: string) {
|
function discoveryOptions(modulesDirectory: string) {
|
||||||
return {
|
return {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
|
|
||||||
const platform = {
|
const platform = {
|
||||||
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
||||||
moduleSdkVersion: "0.1.2",
|
moduleSdkVersion: "0.2.0",
|
||||||
platformVersion: "0.1.2",
|
platformVersion: "0.2.0",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const temporaryDirectories: string[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
|
|||||||
@@ -1905,7 +1905,7 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
|
|||||||
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
|
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
|
||||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
const templateModule = path.join(repositoryRoot, "templates", "module");
|
const templateModule = path.join(repositoryRoot, "templates", "module");
|
||||||
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
const versions = { moduleSdkVersion: "0.2.0", platformVersion: "0.2.0" } as const;
|
||||||
|
|
||||||
async function coreWithoutNewestMigration(directory: string): Promise<string> {
|
async function coreWithoutNewestMigration(directory: string): Promise<string> {
|
||||||
const target = path.join(directory, "core-drizzle");
|
const target = path.join(directory, "core-drizzle");
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function manifestFor(
|
|||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
|||||||
@@ -180,9 +180,9 @@ export async function modulePackageFixture(
|
|||||||
runtime: { node: ">=20" },
|
runtime: { node: ">=20" },
|
||||||
provenance: {
|
provenance: {
|
||||||
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
|
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
|
||||||
packager: { name: "@ekklesia/module-pack", version: "0.1.2" },
|
packager: { name: "@ekklesia/module-pack", version: "0.2.0" },
|
||||||
},
|
},
|
||||||
incorporates: { "@ekklesia/module-sdk": "0.1.2" },
|
incorporates: { "@ekklesia/module-sdk": "0.2.0" },
|
||||||
files: listed,
|
files: listed,
|
||||||
};
|
};
|
||||||
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
|
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
|
||||||
|
|||||||
+2
-2
@@ -500,10 +500,10 @@ finds its files:
|
|||||||
```sh
|
```sh
|
||||||
docker build \
|
docker build \
|
||||||
--file deploy/Dockerfile \
|
--file deploy/Dockerfile \
|
||||||
--build-arg VERSION=0.1.2 \
|
--build-arg VERSION=0.2.0 \
|
||||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
--tag ekklesia:0.1.2 \
|
--tag ekklesia:0.2.0 \
|
||||||
.
|
.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,16 @@ version set ─▶ build ─▶ test ─▶ pack the public package train ─▶
|
|||||||
1. **Version set.** A `chore(release): prepare X` pull request sets the release version: the root
|
1. **Version set.** A `chore(release): prepare X` pull request sets the release version: the root
|
||||||
`package.json`, every platform workspace package (the module template versions as a module, not
|
`package.json`, every platform workspace package (the module template versions as a module, not
|
||||||
with the platform), the image build example in `deploy/README.md`, and the standalone fixture's
|
with the platform), the image build example in `deploy/README.md`, and the standalone fixture's
|
||||||
exact `@ekklesia/module-pack` pin (`tests/fixtures/standalone-module/package.json`). The tag
|
exact `@ekklesia/module-pack` pin and `@ekklesia/*` ranges
|
||||||
must point at a commit that carries the version it names.
|
(`tests/fixtures/standalone-module/package.json`). The tag must point at a commit that carries
|
||||||
|
the version it names.
|
||||||
|
|
||||||
|
A minor version (0.x → 0.(x+1)) is a breaking release before 1.0, so manifests that declare
|
||||||
|
`compatibility.platform` and `compatibility.moduleSdk` as `^0.x.0` no longer match it. In the
|
||||||
|
same pull request, move to the new range every module built for this release (the template, the
|
||||||
|
standalone fixture, the native proof module), the test fixtures that model a module of the
|
||||||
|
current release or the current platform, and the module-development guide's examples. A patch
|
||||||
|
version changes none of these.
|
||||||
2. **Build and test.** `pnpm verify`, the integration suite, and the deploy smoke, as for any change.
|
2. **Build and test.** `pnpm verify`, the integration suite, and the deploy smoke, as for any change.
|
||||||
3. **Pack the public package train.** `pnpm packages:release` (below).
|
3. **Pack the public package train.** `pnpm packages:release` (below).
|
||||||
4. **Standalone consumer proof.** A module outside the repository consumes exactly those
|
4. **Standalone consumer proof.** A module outside the repository consumes exactly those
|
||||||
@@ -111,17 +119,17 @@ registry and is not a registry catalog.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"packageTrain": 1,
|
"packageTrain": 1,
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"source": { "modified": false, "revision": "<full commit id>" },
|
"source": { "modified": false, "revision": "<full commit id>" },
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"file": "ekklesia-contracts-0.1.2.tgz",
|
"file": "ekklesia-contracts-0.2.0.tgz",
|
||||||
"integrity": "sha512-<base64>",
|
"integrity": "sha512-<base64>",
|
||||||
"name": "@ekklesia/contracts",
|
"name": "@ekklesia/contracts",
|
||||||
"role": "implementation",
|
"role": "implementation",
|
||||||
"sha256": "<hex>",
|
"sha256": "<hex>",
|
||||||
"size": 87379,
|
"size": 87379,
|
||||||
"version": "0.1.2"
|
"version": "0.2.0"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -235,9 +243,10 @@ never builds, packs, or edits a package, and it never deletes, unpublishes, or o
|
|||||||
name, size, SHA-256, integrity, `SHA256SUMS` line, and the `package.json` inside it must match.
|
name, size, SHA-256, integrity, `SHA256SUMS` line, and the `package.json` inside it must match.
|
||||||
2. **Publishes in dependency order.** Each package comes after the train packages it depends on,
|
2. **Publishes in dependency order.** Each package comes after the train packages it depends on,
|
||||||
then alphabetically. For each package:
|
then alphabetically. For each package:
|
||||||
- **absent:** `npm publish <file.tgz>` under the staging dist-tag `ekklesia-staging`. npm sends the
|
- **absent:** `npm publish <file.tgz>` under the staging dist-tag `ekklesia-staging`, given the
|
||||||
file's own bytes and runs no script for a file; the registry checks them against their
|
artifact's absolute path (npm runs in a temporary directory of its own), after the file is
|
||||||
integrity and stores them as they are;
|
checked once more against the manifest. npm sends the file's own bytes and runs no script for a
|
||||||
|
file; the registry checks them against their integrity and stores them as they are;
|
||||||
- **present with the same bytes:** accepted as already published, and verified;
|
- **present with the same bytes:** accepted as already published, and verified;
|
||||||
- **present with other bytes:** a hard stop. Nothing is deleted or overwritten, nothing after it
|
- **present with other bytes:** a hard stop. Nothing is deleted or overwritten, nothing after it
|
||||||
is published, and the version is burned ([Immutability](#immutability)).
|
is published, and the version is burned ([Immutability](#immutability)).
|
||||||
@@ -343,7 +352,7 @@ public owners, a publisher team, and an outsider. In CI it publishes the very ar
|
|||||||
|
|
||||||
### The first publication
|
### The first publication
|
||||||
|
|
||||||
The first train published is the **next** platform release version, not 0.1.2. The `v0.1.2` tag
|
The first train published is **0.2.0**, the next platform release, not 0.1.2. The `v0.1.2` tag
|
||||||
predates the curated SDK and this release tooling, so packages built now and called 0.1.2 would
|
predates the curated SDK and this release tooling, so packages built now and called 0.1.2 would
|
||||||
claim to be source that 0.1.2 never contained.
|
claim to be source that 0.1.2 never contained.
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ entrypoints. A module in its own repository depends on the published packages in
|
|||||||
> **Status:** the packages are published by release
|
> **Status:** the packages are published by release
|
||||||
> ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md),
|
> ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md),
|
||||||
> [Releases and the public package train](../development/releases.md)). The first published train
|
> [Releases and the public package train](../development/releases.md)). The first published train
|
||||||
> is the next platform release after 0.1.2.
|
> is 0.2.0, the platform release after 0.1.2.
|
||||||
|
|
||||||
A module does not need an Ekklesia checkout. Do not clone Ekklesia and put your module in its
|
A module does not need an Ekklesia checkout. Do not clone Ekklesia and put your module in its
|
||||||
`modules/` directory: your module is its own repository, and it depends on Ekklesia's
|
`modules/` directory: your module is its own repository, and it depends on Ekklesia's
|
||||||
@@ -131,17 +131,17 @@ import it yourself, because what it exports is the platform's, not a supported a
|
|||||||
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui"
|
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/module-database": "^0.1.2",
|
"@ekklesia/module-database": "^0.2.0",
|
||||||
"@ekklesia/module-sdk": "^0.1.2",
|
"@ekklesia/module-sdk": "^0.2.0",
|
||||||
"@ekklesia/module-ui-sdk": "^0.1.2",
|
"@ekklesia/module-ui-sdk": "^0.2.0",
|
||||||
"@ekklesia/ui": "^0.1.2",
|
"@ekklesia/ui": "^0.2.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ekklesia/module-pack": "0.1.2",
|
"@ekklesia/module-pack": "0.2.0",
|
||||||
"@ekklesia/module-ui-build": "^0.1.2",
|
"@ekklesia/module-ui-build": "^0.2.0",
|
||||||
"@ekklesia/typescript-config": "^0.1.2",
|
"@ekklesia/typescript-config": "^0.2.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,8 +366,8 @@ without executing module code:
|
|||||||
"name": "Calendar",
|
"name": "Calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"modules": {
|
"modules": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ekklesia",
|
"name": "ekklesia",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/contracts",
|
"name": "@ekklesia/contracts",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/design-tokens",
|
"name": "@ekklesia/design-tokens",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-database",
|
"name": "@ekklesia/module-database",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-pack",
|
"name": "@ekklesia/module-pack",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"description": "A minimal native module: its backend uses sharp, so it packages for linux-x64-glibc.",
|
"description": "A minimal native module: its backend uses sharp, so it packages for linux-x64-glibc.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.2.0"
|
||||||
},
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"abi": 1
|
"abi": 1
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ describe.runIf(onTargetHost)("the linux-x64-glibc target, proven with sharp", ()
|
|||||||
extractTo,
|
extractTo,
|
||||||
platform: {
|
platform: {
|
||||||
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
||||||
moduleSdkVersion: "0.1.2",
|
moduleSdkVersion: "0.2.0",
|
||||||
platformVersion: "0.1.2",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).resolves.toMatchObject({ moduleId: "native-proof", target: "linux-x64-glibc" });
|
).resolves.toMatchObject({ moduleId: "native-proof", target: "linux-x64-glibc" });
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
|
|||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version,
|
version,
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
...(options.databaseAbi === 1 ? { database: { abi: 1 } } : {}),
|
...(options.databaseAbi === 1 ? { database: { abi: 1 } } : {}),
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-sdk",
|
"name": "@ekklesia/module-sdk",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-build",
|
"name": "@ekklesia/module-ui-build",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-host",
|
"name": "@ekklesia/module-ui-host",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-ui-sdk",
|
"name": "@ekklesia/module-ui-sdk",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/typescript-config",
|
"name": "@ekklesia/typescript-config",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"files": [
|
"files": [
|
||||||
"base.json",
|
"base.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/ui",
|
"name": "@ekklesia/ui",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export function tarballPackageJson(tarball: Uint8Array): Record<string, unknown>
|
|||||||
|
|
||||||
export interface LocalPackage {
|
export interface LocalPackage {
|
||||||
readonly entry: TrainPackage;
|
readonly entry: TrainPackage;
|
||||||
|
/** The artifact's absolute path: the exact file validated against the release manifest. */
|
||||||
readonly path: string;
|
readonly path: string;
|
||||||
readonly data: Buffer;
|
readonly data: Buffer;
|
||||||
readonly packageJson: Readonly<Record<string, unknown>>;
|
readonly packageJson: Readonly<Record<string, unknown>>;
|
||||||
@@ -129,9 +130,12 @@ export async function loadReleaseTrain(
|
|||||||
releaseDirectory: string,
|
releaseDirectory: string,
|
||||||
expected: { readonly version: string; readonly revision?: string },
|
expected: { readonly version: string; readonly revision?: string },
|
||||||
): Promise<LocalTrain> {
|
): Promise<LocalTrain> {
|
||||||
|
// Resolved once, here: every artifact path from now on is absolute, so the file npm is handed
|
||||||
|
// is the file validated below, whatever directory npm itself runs in.
|
||||||
|
const directory = path.resolve(releaseDirectory);
|
||||||
const files = new Map<string, Buffer>();
|
const files = new Map<string, Buffer>();
|
||||||
for (const file of await readdir(releaseDirectory)) {
|
for (const file of await readdir(directory)) {
|
||||||
files.set(file, await readFile(path.join(releaseDirectory, file)));
|
files.set(file, await readFile(path.join(directory, file)));
|
||||||
}
|
}
|
||||||
const manifestBytes = files.get(PACKAGE_TRAIN_MANIFEST);
|
const manifestBytes = files.get(PACKAGE_TRAIN_MANIFEST);
|
||||||
if (manifestBytes === undefined) {
|
if (manifestBytes === undefined) {
|
||||||
@@ -156,7 +160,7 @@ export async function loadReleaseTrain(
|
|||||||
data,
|
data,
|
||||||
entry,
|
entry,
|
||||||
packageJson: tarballPackageJson(data),
|
packageJson: tarballPackageJson(data),
|
||||||
path: path.join(releaseDirectory, entry.file),
|
path: path.join(directory, entry.file),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -525,9 +529,9 @@ async function npmSession(registry: string, token: string, npm: string): Promise
|
|||||||
const stderr = String((error as { stderr?: unknown }).stderr ?? "");
|
const stderr = String((error as { stderr?: unknown }).stderr ?? "");
|
||||||
const summary = stderr
|
const summary = stderr
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((line) => /^npm error (?:code|\d{3}|[A-Z])/u.test(line))
|
.filter((line) => /^npm error (?:code|path|syscall|errno|enoent|\d{3}|[A-Z])/u.test(line))
|
||||||
.filter((line) => !line.includes("complete log"))
|
.filter((line) => !line.includes("complete log"))
|
||||||
.slice(0, 3)
|
.slice(0, 5)
|
||||||
.join(" / ")
|
.join(" / ")
|
||||||
.replaceAll(token, "***")
|
.replaceAll(token, "***")
|
||||||
.replaceAll(home, "<npm home>");
|
.replaceAll(home, "<npm home>");
|
||||||
@@ -541,13 +545,22 @@ async function npmSession(registry: string, token: string, npm: string): Promise
|
|||||||
addTag: (name, version, tag) =>
|
addTag: (name, version, tag) =>
|
||||||
run(["dist-tag", "add", `${name}@${version}`, tag], `Moving ${tag} to ${name}@${version}`),
|
run(["dist-tag", "add", `${name}@${version}`, tag], `Moving ${tag} to ${name}@${version}`),
|
||||||
dispose: () => rm(home, { force: true, recursive: true }),
|
dispose: () => rm(home, { force: true, recursive: true }),
|
||||||
publish: (file, tag) =>
|
publish: async (file, tag) => {
|
||||||
run(
|
// npm runs in its own temporary home, so a relative path would name a file there, not the
|
||||||
|
// release artifact. Only the artifact's absolute path is ever passed.
|
||||||
|
if (!path.isAbsolute(file)) {
|
||||||
|
throw new PublicationError(
|
||||||
|
"PUBLISH_FAILED",
|
||||||
|
`Refusing to publish ${file}: npm is only ever given an artifact's absolute path.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await run(
|
||||||
// A file spec runs no lifecycle script and sends the file's own bytes; --ignore-scripts
|
// A file spec runs no lifecycle script and sends the file's own bytes; --ignore-scripts
|
||||||
// says so explicitly. --access public is what a scoped package needs on a public registry.
|
// says so explicitly. --access public is what a scoped package needs on a public registry.
|
||||||
["publish", file, "--tag", tag, "--access", "public", "--ignore-scripts"],
|
["publish", file, "--tag", tag, "--access", "public", "--ignore-scripts"],
|
||||||
`Publishing ${path.basename(file)}`,
|
`Publishing ${path.basename(file)} from ${file}`,
|
||||||
),
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +690,15 @@ export async function publishPackageTrain(options: PublishOptions): Promise<Publ
|
|||||||
let existing = versionOf(await reader.packument(name), version);
|
let existing = versionOf(await reader.packument(name), version);
|
||||||
if (existing === undefined) {
|
if (existing === undefined) {
|
||||||
await options.hooks?.beforePublish?.(name, index);
|
await options.hooks?.beforePublish?.(name, index);
|
||||||
|
// The file npm reads must still be the validated artifact, byte for byte.
|
||||||
|
const current = await readFile(local.path).catch(() => undefined);
|
||||||
|
if (current === undefined || !current.equals(local.data)) {
|
||||||
|
throw new PublicationError(
|
||||||
|
"INVALID_RELEASE",
|
||||||
|
`${local.path} is missing or no longer the artifact validated against the release manifest.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(`${name}@${version}: publishing ${local.path} (sha256 ${local.entry.sha256})`);
|
||||||
try {
|
try {
|
||||||
await npm.publish(local.path, stagingTag);
|
await npm.publish(local.path, stagingTag);
|
||||||
outcome = "published";
|
outcome = "published";
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ for user in publisher outsider; do
|
|||||||
password_variable="${user}_password"
|
password_variable="${user}_password"
|
||||||
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
|
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
|
||||||
done
|
done
|
||||||
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry)
|
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
|
||||||
for owner in "${owners[@]}"; do
|
for owner in "${owners[@]}"; do
|
||||||
api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null
|
api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null
|
||||||
team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' |
|
team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/shell",
|
"name": "@ekklesia/shell",
|
||||||
"version": "0.1.2",
|
"version": "0.2.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
|
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.2.0"
|
||||||
},
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"abi": 1
|
"abi": 1
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
"description": "Proves a module builds, packages, and verifies from published Ekklesia packages alone.",
|
"description": "Proves a module builds, packages, and verifies from published Ekklesia packages alone.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.2.0"
|
||||||
},
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"abi": 1
|
"abi": 1
|
||||||
|
|||||||
+8
-8
@@ -13,18 +13,18 @@
|
|||||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/design-tokens": "^0.1.2",
|
"@ekklesia/design-tokens": "^0.2.0",
|
||||||
"@ekklesia/module-database": "^0.1.2",
|
"@ekklesia/module-database": "^0.2.0",
|
||||||
"@ekklesia/module-sdk": "^0.1.2",
|
"@ekklesia/module-sdk": "^0.2.0",
|
||||||
"@ekklesia/module-ui-sdk": "^0.1.2",
|
"@ekklesia/module-ui-sdk": "^0.2.0",
|
||||||
"@ekklesia/ui": "^0.1.2",
|
"@ekklesia/ui": "^0.2.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ekklesia/module-pack": "0.1.2",
|
"@ekklesia/module-pack": "0.2.0",
|
||||||
"@ekklesia/module-ui-build": "^0.1.2",
|
"@ekklesia/module-ui-build": "^0.2.0",
|
||||||
"@ekklesia/typescript-config": "^0.1.2",
|
"@ekklesia/typescript-config": "^0.2.0",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.2.4",
|
"@types/react-dom": "19.2.4",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
@@ -28,8 +28,8 @@ const origin = setting("EKKLESIA_TEST_REGISTRY_ORIGIN");
|
|||||||
const releaseDirectory = setting("EKKLESIA_TEST_RELEASE");
|
const releaseDirectory = setting("EKKLESIA_TEST_RELEASE");
|
||||||
const publishToken = setting("EKKLESIA_TEST_PUBLISH_TOKEN");
|
const publishToken = setting("EKKLESIA_TEST_PUBLISH_TOKEN");
|
||||||
const outsiderToken = setting("EKKLESIA_TEST_OUTSIDER_TOKEN");
|
const outsiderToken = setting("EKKLESIA_TEST_OUTSIDER_TOKEN");
|
||||||
const [mainOwner, burnedOwner, tagOwner] = setting("EKKLESIA_TEST_OWNERS").split(" ");
|
const [mainOwner, burnedOwner, tagOwner, layoutOwner] = setting("EKKLESIA_TEST_OWNERS").split(" ");
|
||||||
if (!origin || !releaseDirectory || !publishToken || !outsiderToken || !tagOwner) {
|
if (!origin || !releaseDirectory || !publishToken || !outsiderToken || !layoutOwner) {
|
||||||
throw new Error("Run this suite with scripts/test-package-publication.sh.");
|
throw new Error("Run this suite with scripts/test-package-publication.sh.");
|
||||||
}
|
}
|
||||||
const registryOf = (owner: string | undefined) =>
|
const registryOf = (owner: string | undefined) =>
|
||||||
@@ -303,3 +303,57 @@ describe("moving the final tag, interrupted and resumed", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the publish workflow's own invocation", () => {
|
||||||
|
it("publishes from the Actions artifact layout, given a relative release path from another directory", async () => {
|
||||||
|
// Exactly as .github/workflows/publish-packages.yml runs it: the artifact unpacked under
|
||||||
|
// `package-train/` in the job's workspace, and `--release "$(find package-train …)"`.
|
||||||
|
const workspace = await mkdtemp(path.join(tmpdir(), "ekklesia-publish-workspace-"));
|
||||||
|
scratch.push(workspace);
|
||||||
|
const releaseName = path.basename(releaseDirectory);
|
||||||
|
await mkdir(path.join(workspace, "package-train"), { recursive: true });
|
||||||
|
await cp(releaseDirectory, path.join(workspace, "package-train", releaseName), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
const record = path.join(workspace, "publication-record.json");
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.resolve("scripts/publish-package-train.ts"),
|
||||||
|
"--release",
|
||||||
|
`package-train/${releaseName}`,
|
||||||
|
"--registry",
|
||||||
|
registryOf(layoutOwner),
|
||||||
|
"--version",
|
||||||
|
train.manifest.version,
|
||||||
|
"--revision",
|
||||||
|
train.manifest.source.revision,
|
||||||
|
"--manifest-sha256",
|
||||||
|
train.manifestSha256,
|
||||||
|
"--record",
|
||||||
|
record,
|
||||||
|
"--require-public-read",
|
||||||
|
"--allow-http",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: workspace,
|
||||||
|
env: { [PUBLISH_TOKEN_VARIABLE]: publishToken, PATH: process.env.PATH ?? "" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(stdout).toContain(
|
||||||
|
`publishing ${path.join(workspace, "package-train", releaseName, local("@ekklesia/contracts").entry.file)}`,
|
||||||
|
);
|
||||||
|
expect(stdout).not.toContain(publishToken);
|
||||||
|
const published = JSON.parse(await readFile(record, "utf8")) as PublicationRecord;
|
||||||
|
expect(published.packages.map((entry) => entry.outcome)).toEqual(Array(9).fill("published"));
|
||||||
|
for (const name of order) {
|
||||||
|
const version = (await anonymousPackument(layoutOwner, name))?.versions[
|
||||||
|
train.manifest.version
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
(await anonymousBytes(version?.dist.tarball ?? "")).equals(local(name).data),
|
||||||
|
name,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -462,6 +462,119 @@ describe("the registry and its credentials", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the artifact npm is given", () => {
|
||||||
|
/**
|
||||||
|
* The release where the Actions artifact puts it, `<workspace>/package-train/<release>`, named
|
||||||
|
* relatively from the current directory, which is not the release directory: exactly how the
|
||||||
|
* publish workflow called the tool when npm was handed a path relative to its own temporary home.
|
||||||
|
*/
|
||||||
|
async function nestedRelease(): Promise<{ relative: string; absolute: string }> {
|
||||||
|
const workspace = await mkdtemp(path.join(tmpdir(), "ekklesia-workspace-"));
|
||||||
|
scratch.push(workspace);
|
||||||
|
const absolute = path.join(workspace, "package-train", `ekklesia-packages-${VERSION}`);
|
||||||
|
await mkdir(path.dirname(absolute), { recursive: true });
|
||||||
|
await cp(await releaseDirectory(), absolute, { recursive: true });
|
||||||
|
const relative = path.relative(process.cwd(), absolute);
|
||||||
|
expect(path.isAbsolute(relative)).toBe(false);
|
||||||
|
expect(process.cwd()).not.toBe(absolute);
|
||||||
|
return { absolute, relative };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An npm that records what it was given, and where it ran, then fails as a registry would. */
|
||||||
|
async function recordingNpm(): Promise<{
|
||||||
|
npm: string;
|
||||||
|
calls: () => Promise<{ argv: string[]; cwd: string; exists: boolean }[]>;
|
||||||
|
}> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-fake-npm-"));
|
||||||
|
scratch.push(directory);
|
||||||
|
const log = path.join(directory, "calls.jsonl");
|
||||||
|
const npm = path.join(directory, "npm");
|
||||||
|
await writeFile(
|
||||||
|
npm,
|
||||||
|
`#!/usr/bin/env node
|
||||||
|
const { appendFileSync, existsSync } = require("node:fs");
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
appendFileSync(${JSON.stringify(log)}, JSON.stringify({ argv, cwd: process.cwd(), exists: existsSync(argv[1] ?? "") }) + "\\n");
|
||||||
|
process.stderr.write("npm error code E503\\nnpm error 503 Service Unavailable\\n");
|
||||||
|
process.exit(1);
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await chmod(npm, 0o755);
|
||||||
|
return {
|
||||||
|
calls: async () =>
|
||||||
|
(await readFile(log, "utf8").catch(() => ""))
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line)),
|
||||||
|
npm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const notFound = (async () => new Response("{}", { status: 404 })) as typeof fetch;
|
||||||
|
|
||||||
|
it("resolves a relative, nested release to absolute artifact paths when it loads the train", async () => {
|
||||||
|
const { absolute, relative } = await nestedRelease();
|
||||||
|
const train = await loadReleaseTrain(relative, { revision: REVISION, version: VERSION });
|
||||||
|
for (const local of train.packages) {
|
||||||
|
expect(local.path).toBe(path.join(absolute, local.entry.file));
|
||||||
|
expect(path.isAbsolute(local.path)).toBe(true);
|
||||||
|
expect((await readFile(local.path)).equals(local.data)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands npm the exact validated tarball by its absolute path, from npm's own directory, and says which", async () => {
|
||||||
|
const { absolute, relative } = await nestedRelease();
|
||||||
|
const { calls, npm } = await recordingNpm();
|
||||||
|
const error = await publishPackageTrain({
|
||||||
|
fetch: notFound,
|
||||||
|
npm,
|
||||||
|
registry: REGISTRY,
|
||||||
|
releaseDirectory: relative,
|
||||||
|
revision: REVISION,
|
||||||
|
token: "publish-token-value",
|
||||||
|
version: VERSION,
|
||||||
|
}).catch((caught: unknown) => caught);
|
||||||
|
expect(error).toMatchObject({ code: "PUBLISH_FAILED" });
|
||||||
|
const first = tarballName("@ekklesia/contracts", VERSION);
|
||||||
|
const expected = path.join(absolute, first);
|
||||||
|
// The failure names the exact file npm was given, and never the token.
|
||||||
|
expect((error as Error).message).toContain(expected);
|
||||||
|
expect((error as Error).message).toContain("E503");
|
||||||
|
expect((error as Error).message).not.toContain("publish-token-value");
|
||||||
|
|
||||||
|
const [call] = await calls();
|
||||||
|
expect(call?.argv.slice(0, 2)).toEqual(["publish", expected]);
|
||||||
|
expect(call?.exists).toBe(true);
|
||||||
|
// npm ran in a directory of its own, where a relative path would have named nothing.
|
||||||
|
expect(call?.cwd).not.toBe(absolute);
|
||||||
|
expect(path.isAbsolute(call?.cwd ?? "")).toBe(true);
|
||||||
|
expect(JSON.stringify(call)).not.toContain("publish-token-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to publish an artifact that changed after it was validated, before npm runs", async () => {
|
||||||
|
const { absolute, relative } = await nestedRelease();
|
||||||
|
const { calls, npm } = await recordingNpm();
|
||||||
|
const file = path.join(absolute, tarballName("@ekklesia/contracts", VERSION));
|
||||||
|
await expect(
|
||||||
|
publishPackageTrain({
|
||||||
|
fetch: notFound,
|
||||||
|
hooks: {
|
||||||
|
beforePublish: async (_name, index) => {
|
||||||
|
if (index === 0) await writeFile(file, "changed after validation");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
npm,
|
||||||
|
registry: REGISTRY,
|
||||||
|
releaseDirectory: relative,
|
||||||
|
revision: REVISION,
|
||||||
|
token: "publish-token-value",
|
||||||
|
version: VERSION,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "INVALID_RELEASE" });
|
||||||
|
expect(await calls()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("what the publisher may do to a registry", () => {
|
describe("what the publisher may do to a registry", () => {
|
||||||
it("never deletes, unpublishes, or overwrites: it only publishes new versions and moves a tag", async () => {
|
it("never deletes, unpublishes, or overwrites: it only publishes new versions and moves a tag", async () => {
|
||||||
// Code only: the tool's comments may say what it never does.
|
// Code only: the tool's comments may say what it never does.
|
||||||
|
|||||||
Reference in New Issue
Block a user