@ankhorage/devtools 1.14.8 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +113 -1
  2. package/dist/apmRelease.d.ts +4 -0
  3. package/dist/apmRelease.js +3 -0
  4. package/dist/cli/bin/apm-release.d.ts +2 -0
  5. package/dist/cli/bin/apm-release.js +15 -0
  6. package/dist/cli/commands/apm/sync.d.ts +5 -0
  7. package/dist/cli/commands/apm/sync.js +5 -0
  8. package/dist/cli/commands/apm/validate.d.ts +5 -0
  9. package/dist/cli/commands/apm/validate.js +5 -0
  10. package/dist/cli/commands.d.ts +6 -2
  11. package/dist/cli/commands.js +15 -1
  12. package/dist/cli/index.d.ts +2 -2
  13. package/dist/cli/index.js +9 -0
  14. package/dist/cli/runProviderCommand.js +5 -0
  15. package/dist/features/apm-release-validation/adapters/inbound/runApmReleaseCommandAsync.d.ts +10 -0
  16. package/dist/features/apm-release-validation/adapters/inbound/runApmReleaseCommandAsync.js +51 -0
  17. package/dist/features/apm-release-validation/adapters/outbound/createApmReleaseDescriptorPort.d.ts +3 -0
  18. package/dist/features/apm-release-validation/adapters/outbound/createApmReleaseDescriptorPort.js +31 -0
  19. package/dist/features/apm-release-validation/adapters/outbound/inspectPackedApmReleaseCandidateAsync.d.ts +3 -0
  20. package/dist/features/apm-release-validation/adapters/outbound/inspectPackedApmReleaseCandidateAsync.js +84 -0
  21. package/dist/features/apm-release-validation/adapters/outbound/probePackedApmExtensionAsync.d.ts +11 -0
  22. package/dist/features/apm-release-validation/adapters/outbound/probePackedApmExtensionAsync.js +82 -0
  23. package/dist/features/apm-release-validation/adapters/outbound/readApmReleaseManifestAsync.d.ts +2 -0
  24. package/dist/features/apm-release-validation/adapters/outbound/readApmReleaseManifestAsync.js +9 -0
  25. package/dist/features/apm-release-validation/adapters/outbound/resolveApmReleaseCommandAsync.d.ts +2 -0
  26. package/dist/features/apm-release-validation/adapters/outbound/resolveApmReleaseCommandAsync.js +18 -0
  27. package/dist/features/apm-release-validation/application/synchronizeApmReleaseMetadataAsync.d.ts +3 -0
  28. package/dist/features/apm-release-validation/application/synchronizeApmReleaseMetadataAsync.js +29 -0
  29. package/dist/features/apm-release-validation/application/validateApmReleaseCandidate.d.ts +11 -0
  30. package/dist/features/apm-release-validation/application/validateApmReleaseCandidate.js +76 -0
  31. package/dist/features/apm-release-validation/composition/synchronizeApmReleaseDescriptorAsync.d.ts +2 -0
  32. package/dist/features/apm-release-validation/composition/synchronizeApmReleaseDescriptorAsync.js +6 -0
  33. package/dist/features/apm-release-validation/composition/validatePackedApmReleaseAsync.d.ts +12 -0
  34. package/dist/features/apm-release-validation/composition/validatePackedApmReleaseAsync.js +14 -0
  35. package/dist/features/apm-release-validation/utils/readApmReleaseMetadata.d.ts +2 -0
  36. package/dist/features/apm-release-validation/utils/readApmReleaseMetadata.js +6 -0
  37. package/dist/tools/workflows/files/ci.yml +7 -0
  38. package/dist/tools/workflows/files/release.yml +27 -1
  39. package/dist/tools/workflows/index.js +4 -1
  40. package/dist/tools/workflows/renderWorkflowAsync.d.ts +1 -0
  41. package/dist/tools/workflows/renderWorkflowAsync.js +1 -0
  42. package/dist/types/apm-release-validation.d.ts +33 -0
  43. package/dist/types/apm-release-validation.js +1 -0
  44. package/dist/types/public.d.ts +1 -0
  45. package/dist/types/public.js +1 -0
  46. package/package.json +17 -4
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # @ankhorage/devtools
5
5
 
6
- ![license: MIT](././paradox/badges/license.svg) ![npm: v1.14.7](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
6
+ ![license: MIT](././paradox/badges/license.svg) ![npm: v1.14.8](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
7
7
 
8
8
  Shared tooling, repository automation, runtime policies, and agent standards for Ankhorage TypeScript projects
9
9
 
@@ -68,6 +68,8 @@ Synchronization ensures `@ankhorage/devtools` is declared using the version of t
68
68
 
69
69
  The package is discovered under the `devtools` category and exposes these capabilities:
70
70
 
71
+ - `devtools.apm.sync`
72
+ - `devtools.apm.validate`
71
73
  - `devtools.lint`
72
74
  - `devtools.changeset`
73
75
  - `devtools.format`
@@ -385,6 +387,59 @@ A new concern should:
385
387
  5. include dry-run, status, and idempotence coverage
386
388
  6. document its central ownership and repository-owned extension points
387
389
 
390
+ ## Package-owned APM release gates
391
+
392
+ A package opts in through `ankh.apm` in its `package.json`:
393
+
394
+ ```json
395
+ {
396
+ "ankh": {
397
+ "apm": { "protocolVersion": 1, "descriptor": "./apm/update.json" }
398
+ }
399
+ }
400
+ ```
401
+
402
+ Include the descriptor in `files` and expose any executable extension with an explicit public
403
+ `exports` subpath. Use the released APM descriptor and extension types from `@ankhorage/apm/types`;
404
+ Devtools does not own or duplicate the protocol. Add `test:apm` to run the owner's deterministic
405
+ source-to-target fixtures, including skipped versions, unsupported old states, idempotency and
406
+ interruption recovery. A no-migration release still declares supported history explicitly and
407
+ can pass without an extension. Absence of metadata is reported as inapplicable, not migration-safe.
408
+
409
+ ```sh
410
+ ankh devtools apm sync .
411
+ ankh devtools apm validate . --allow-owner-code --artifact /tmp/reviewed-owner.tgz
412
+ ```
413
+
414
+ The standalone `ankhorage-apm-release sync|validate` binary runs the same operations. The public
415
+ `@ankhorage/devtools/apm-release` entrypoint exports `synchronizeApmReleaseDescriptorAsync`,
416
+ `validatePackedApmReleaseAsync` and `validateApmReleaseCandidate`; types are available from
417
+ `@ankhorage/devtools/types`. Programmatic callers can supply `previousDescriptors` to enforce
418
+ unchanged migration checksums and `relatedDescriptors` for cross-owner prerequisites. Owners
419
+ must include historical descriptors in their `test:apm` fixtures where their migrations require them.
420
+
421
+ Validation packs with lifecycle scripts disabled, reads only the packed descriptor, checks owner
422
+ identity, protocol/schema, migration/projection IDs, prerequisite graphs and public extension
423
+ capabilities using APM's canonical validators. Invalid metadata is a failure, not an opt-out.
424
+ `--allow-owner-code` is explicit permission to import the packed extension in a bounded child
425
+ process. The child is **not a security sandbox**; run only trusted owner code. Registry metadata
426
+ alone never grants this permission. Installed dependency resolution uses the repository's
427
+ frozen dependency graph; the package's own code is loaded through native public export resolution
428
+ from the extracted archive, never from the source checkout.
429
+
430
+ `--artifact` creates a new file containing exactly the validated bytes and refuses an existing
431
+ output. The JSON result includes SHA-512 integrity; `--expected-integrity` refuses a different
432
+ archive. Keep the retained archive immutable. The managed release workflow versions with
433
+ Changesets, synchronizes descriptor identity, rebuilds, runs `test:apm`, validates a fresh archive,
434
+ and publishes that same archive using `npm publish --ignore-scripts`, without repacking.
435
+ Authentication and network errors are not treated as an unpublished version. Packages without
436
+ APM metadata retain their existing Changesets publication path.
437
+
438
+ Producer order is: released APM protocol, validated owner package release, then consumer rollout.
439
+ Never declare an unpublished owner version. Project migration recovery is owned by APM and the
440
+ package's declared handlers; passing this release gate does not execute migrations against user
441
+ projects, deploy production services, or prove compatibility with shipped native binaries.
442
+
388
443
  ## CLI
389
444
 
390
445
  Run and synchronize the shared development toolchain through the Ankh CLI.
@@ -404,7 +459,17 @@ Fresh repositories can bootstrap the standard setup with `ankh devtools sync .`
404
459
  `@ankhorage/devtools`. Existing ESLint configuration is preserved during first migration as a
405
460
  local extension before the canonical auto-detecting wrapper is installed.
406
461
 
462
+ APM owner releases opt in through `ankh.apm`. `ankh devtools apm sync .` binds a valid
463
+ descriptor to the selected Changesets version. `ankh devtools apm validate .` checks a
464
+ script-free tarball; `--allow-owner-code` explicitly permits the isolated executable probe.
465
+ `--artifact <path>` retains the exact accepted tarball and `--expected-integrity <SRI>`
466
+ rejects changed bytes. Owners provide `test:apm` for source-to-target and recovery fixtures.
467
+ CI runs that owner suite and the packed check. Release rebuilds after versioning, validates,
468
+ then publishes the retained archive with scripts disabled through the existing release job.
469
+ Packages without metadata keep their existing Changesets publish path unchanged.
470
+
407
471
  ```bash
472
+ bunx @ankhorage/devtools ankhorage-apm-release
408
473
  bunx @ankhorage/devtools ankhorage-changeset
409
474
  bunx @ankhorage/devtools ankhorage-eslint
410
475
  bunx @ankhorage/devtools ankhorage-knip
@@ -420,3 +485,50 @@ bunx @ankhorage/devtools ankhorage-prettier
420
485
  - [Module relationships](././paradox/diagrams/module-relationships.mmd)
421
486
  - [Export graph](././paradox/diagrams/export-graph.mmd)
422
487
  - [createKnipMonorepoConfig sequence](././paradox/diagrams/sequences/create-knip-monorepo-config.mmd)
488
+
489
+ ## Public API
490
+
491
+ ### Utilities
492
+
493
+ <details>
494
+ <summary>validateApmReleaseCandidate</summary>
495
+
496
+ ```ts
497
+ validateApmReleaseCandidate(candidate: ApmPackedReleaseCandidate, options?: ApmReleaseValidationOptions) => ApmReleaseValidationResult
498
+ ```
499
+
500
+ Validate packed owner evidence using the released APM protocol, never a copied schema.
501
+ Opt-in packages declare ankh.apm with protocolVersion and a package-relative descriptor.
502
+ No metadata means not applicable, not a claim that no migration is needed. The descriptor
503
+ declares supported no-migration, required migration, or unsupported/manual history explicitly.
504
+ Previous descriptors enforce immutable migration checksums and related descriptors validate
505
+ cross-owner prerequisites. Package-owned source-to-target tests must also cover skipped versions,
506
+ idempotency, interruption/recovery, and unsupported historical states.
507
+
508
+ Module: `src/features/apm-release-validation/application/validateApmReleaseCandidate.ts`
509
+ Source: `src/features/apm-release-validation/application/validateApmReleaseCandidate.ts:31:1`
510
+ Related symbols: `ApmPackedReleaseCandidate`, `ApmReleaseValidationOptions`, `ApmReleaseValidationResult`
511
+
512
+ </details>
513
+
514
+ <details>
515
+ <summary>validatePackedApmReleaseAsync</summary>
516
+
517
+ ```ts
518
+ validatePackedApmReleaseAsync(targetDirectory: string, options?: ApmReleaseValidationOptions) => Promise<ApmReleaseValidationResult>
519
+ ```
520
+
521
+ Validate script-free publish bytes and optionally retain that exact accepted archive.
522
+ Release order is APM protocol, package-owner metadata/code, then consuming applications.
523
+ Build after Changesets versioning and descriptor synchronization. Execute package-owned
524
+ acceptance tests, then validate with explicit owner-code consent. The child probe uses the
525
+ packed package's native public export resolution and installed dependencies, not omitted source.
526
+ Consent permits trusted executable code; a child process is not a security sandbox.
527
+ An artifactPath retains accepted bytes exclusively, so publication need not repack or rerun
528
+ lifecycle scripts. expectedIntegrity rejects a stale candidate before any owner code loads.
529
+
530
+ Module: `src/features/apm-release-validation/composition/validatePackedApmReleaseAsync.ts`
531
+ Source: `src/features/apm-release-validation/composition/validatePackedApmReleaseAsync.ts:17:1`
532
+ Related symbols: `ApmReleaseValidationOptions`, `ApmReleaseValidationResult`
533
+
534
+ </details>
@@ -0,0 +1,4 @@
1
+ export { validateApmReleaseCandidate } from './features/apm-release-validation/application/validateApmReleaseCandidate.js';
2
+ export { synchronizeApmReleaseDescriptorAsync } from './features/apm-release-validation/composition/synchronizeApmReleaseDescriptorAsync.js';
3
+ export { validatePackedApmReleaseAsync } from './features/apm-release-validation/composition/validatePackedApmReleaseAsync.js';
4
+ export type { ApmPackedReleaseCandidate, ApmReleaseValidationOptions, ApmReleaseValidationResult, } from './types/apm-release-validation.js';
@@ -0,0 +1,3 @@
1
+ export { validateApmReleaseCandidate } from './features/apm-release-validation/application/validateApmReleaseCandidate.js';
2
+ export { synchronizeApmReleaseDescriptorAsync } from './features/apm-release-validation/composition/synchronizeApmReleaseDescriptorAsync.js';
3
+ export { validatePackedApmReleaseAsync } from './features/apm-release-validation/composition/validatePackedApmReleaseAsync.js';
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { runApmReleaseCommandAsync } from '../../features/apm-release-validation/adapters/inbound/runApmReleaseCommandAsync.js';
3
+ const [operation, ...argv] = process.argv.slice(2);
4
+ if (operation !== 'sync' && operation !== 'validate') {
5
+ console.error('Usage: ankhorage-apm-release <sync|validate> [directory] [--allow-owner-code] [--artifact path] [--expected-integrity value]');
6
+ process.exitCode = 1;
7
+ }
8
+ else {
9
+ const result = await runApmReleaseCommandAsync(operation, argv, {
10
+ cwd: process.cwd(),
11
+ writeStdout: (text) => process.stdout.write(text),
12
+ writeStderr: (text) => process.stderr.write(text),
13
+ });
14
+ process.exitCode = result.exitCode;
15
+ }
@@ -0,0 +1,5 @@
1
+ import type { DevtoolsProviderCommandContext } from '../../runProviderCommand.js';
2
+ /*** Adapt the public APM release sync command to the shared release boundary. */
3
+ export declare function sync(argv: readonly string[], context: DevtoolsProviderCommandContext): Promise<{
4
+ readonly exitCode: number;
5
+ }>;
@@ -0,0 +1,5 @@
1
+ import { runApmReleaseCommandAsync } from '../../../features/apm-release-validation/adapters/inbound/runApmReleaseCommandAsync.js';
2
+ /*** Adapt the public APM release sync command to the shared release boundary. */
3
+ export async function sync(argv, context) {
4
+ return runApmReleaseCommandAsync('sync', argv, context);
5
+ }
@@ -0,0 +1,5 @@
1
+ import type { DevtoolsProviderCommandContext } from '../../runProviderCommand.js';
2
+ /*** Adapt the public APM release validate command to the shared release boundary. */
3
+ export declare function validate(argv: readonly string[], context: DevtoolsProviderCommandContext): Promise<{
4
+ readonly exitCode: number;
5
+ }>;
@@ -0,0 +1,5 @@
1
+ import { runApmReleaseCommandAsync } from '../../../features/apm-release-validation/adapters/inbound/runApmReleaseCommandAsync.js';
2
+ /*** Adapt the public APM release validate command to the shared release boundary. */
3
+ export async function validate(argv, context) {
4
+ return runApmReleaseCommandAsync('validate', argv, context);
5
+ }
@@ -1,7 +1,7 @@
1
1
  export type DevtoolsToolName = 'changeset' | 'format' | 'knip' | 'lint';
2
2
  type DevtoolsManagedScope = 'agents' | 'all' | 'eslint' | 'knip' | 'package' | 'prettier' | 'skills' | 'vscode' | 'workflows';
3
3
  type DevtoolsManagedOperation = 'status' | 'sync';
4
- type DevtoolsCapability = 'devtools.changeset' | 'devtools.format' | 'devtools.knip' | 'devtools.lint' | 'devtools.status' | 'devtools.sync' | 'devtools.agents.status' | 'devtools.agents.sync' | 'devtools.eslint.status' | 'devtools.eslint.sync' | 'devtools.knip.status' | 'devtools.knip.sync' | 'devtools.package.status' | 'devtools.package.sync' | 'devtools.prettier.status' | 'devtools.prettier.sync' | 'devtools.skills.status' | 'devtools.skills.sync' | 'devtools.vscode.status' | 'devtools.vscode.sync' | 'devtools.workflows.status' | 'devtools.workflows.sync';
4
+ type DevtoolsCapability = 'devtools.apm.sync' | 'devtools.apm.validate' | 'devtools.changeset' | 'devtools.format' | 'devtools.knip' | 'devtools.lint' | 'devtools.status' | 'devtools.sync' | 'devtools.agents.status' | 'devtools.agents.sync' | 'devtools.eslint.status' | 'devtools.eslint.sync' | 'devtools.knip.status' | 'devtools.knip.sync' | 'devtools.package.status' | 'devtools.package.sync' | 'devtools.prettier.status' | 'devtools.prettier.sync' | 'devtools.skills.status' | 'devtools.skills.sync' | 'devtools.vscode.status' | 'devtools.vscode.sync' | 'devtools.workflows.status' | 'devtools.workflows.sync';
5
5
  interface DevtoolsCommandBase {
6
6
  readonly path: readonly [string, ...string[]];
7
7
  readonly capability: DevtoolsCapability;
@@ -18,7 +18,11 @@ export interface DevtoolsRepositoryCommandDefinition extends DevtoolsCommandBase
18
18
  readonly scope: DevtoolsManagedScope;
19
19
  readonly operation: DevtoolsManagedOperation;
20
20
  }
21
- export type DevtoolsCommandDefinition = DevtoolsExternalCommandDefinition | DevtoolsRepositoryCommandDefinition;
21
+ interface DevtoolsApmReleaseCommandDefinition extends DevtoolsCommandBase {
22
+ readonly kind: 'apm-release';
23
+ readonly operation: 'sync' | 'validate';
24
+ }
25
+ export type DevtoolsCommandDefinition = DevtoolsExternalCommandDefinition | DevtoolsRepositoryCommandDefinition | DevtoolsApmReleaseCommandDefinition;
22
26
  export declare function getDevtoolsCommands(): readonly DevtoolsCommandDefinition[];
23
27
  export declare function findDevtoolsCommandByPath(path: readonly string[]): DevtoolsCommandDefinition | null;
24
28
  export declare function getDevtoolsToolCommand(toolName: DevtoolsToolName): DevtoolsExternalCommandDefinition;
@@ -1,4 +1,18 @@
1
1
  const DEVTOOLS_COMMANDS = [
2
+ {
3
+ kind: 'apm-release',
4
+ operation: 'sync',
5
+ path: ['apm', 'sync'],
6
+ capability: 'devtools.apm.sync',
7
+ summary: 'Bind owner metadata to the Changesets release version.',
8
+ },
9
+ {
10
+ kind: 'apm-release',
11
+ operation: 'validate',
12
+ path: ['apm', 'validate'],
13
+ capability: 'devtools.apm.validate',
14
+ summary: 'Validate the exact packed APM release artifact.',
15
+ },
2
16
  externalCommand('changeset', 'devtools.changeset', 'Run the shared Changesets toolchain.', '@changesets/cli', 'changeset'),
3
17
  externalCommand('lint', 'devtools.lint', 'Run the shared ESLint toolchain.', 'eslint', 'eslint'),
4
18
  externalCommand('format', 'devtools.format', 'Run the shared Prettier toolchain.', 'prettier', 'prettier'),
@@ -27,7 +41,7 @@ export function getDevtoolsCommands() {
27
41
  }
28
42
  export function findDevtoolsCommandByPath(path) {
29
43
  return (DEVTOOLS_COMMANDS.find((command) => command.path.length === path.length &&
30
- command.path.every((segment, index) => segment === path[index])) ?? null);
44
+ command.path.every((segment, index) => segment === path.at(index))) ?? null);
31
45
  }
32
46
  export function getDevtoolsToolCommand(toolName) {
33
47
  for (const command of DEVTOOLS_COMMANDS) {
@@ -2,10 +2,10 @@ declare const provider: {
2
2
  id: string;
3
3
  category: string;
4
4
  version: string;
5
- capabilities: ("devtools.changeset" | "devtools.format" | "devtools.knip" | "devtools.lint" | "devtools.status" | "devtools.sync" | "devtools.agents.status" | "devtools.agents.sync" | "devtools.eslint.status" | "devtools.eslint.sync" | "devtools.knip.status" | "devtools.knip.sync" | "devtools.package.status" | "devtools.package.sync" | "devtools.prettier.status" | "devtools.prettier.sync" | "devtools.skills.status" | "devtools.skills.sync" | "devtools.vscode.status" | "devtools.vscode.sync" | "devtools.workflows.status" | "devtools.workflows.sync")[];
5
+ capabilities: ("devtools.apm.sync" | "devtools.apm.validate" | "devtools.changeset" | "devtools.format" | "devtools.knip" | "devtools.lint" | "devtools.status" | "devtools.sync" | "devtools.agents.status" | "devtools.agents.sync" | "devtools.eslint.status" | "devtools.eslint.sync" | "devtools.knip.status" | "devtools.knip.sync" | "devtools.package.status" | "devtools.package.sync" | "devtools.prettier.status" | "devtools.prettier.sync" | "devtools.skills.status" | "devtools.skills.sync" | "devtools.vscode.status" | "devtools.vscode.sync" | "devtools.workflows.status" | "devtools.workflows.sync")[];
6
6
  commands: {
7
7
  path: string[];
8
- capability: "devtools.changeset" | "devtools.format" | "devtools.knip" | "devtools.lint" | "devtools.status" | "devtools.sync" | "devtools.agents.status" | "devtools.agents.sync" | "devtools.eslint.status" | "devtools.eslint.sync" | "devtools.knip.status" | "devtools.knip.sync" | "devtools.package.status" | "devtools.package.sync" | "devtools.prettier.status" | "devtools.prettier.sync" | "devtools.skills.status" | "devtools.skills.sync" | "devtools.vscode.status" | "devtools.vscode.sync" | "devtools.workflows.status" | "devtools.workflows.sync";
8
+ capability: "devtools.apm.sync" | "devtools.apm.validate" | "devtools.changeset" | "devtools.format" | "devtools.knip" | "devtools.lint" | "devtools.status" | "devtools.sync" | "devtools.agents.status" | "devtools.agents.sync" | "devtools.eslint.status" | "devtools.eslint.sync" | "devtools.knip.status" | "devtools.knip.sync" | "devtools.package.status" | "devtools.package.sync" | "devtools.prettier.status" | "devtools.prettier.sync" | "devtools.skills.status" | "devtools.skills.sync" | "devtools.vscode.status" | "devtools.vscode.sync" | "devtools.workflows.status" | "devtools.workflows.sync";
9
9
  summary: string;
10
10
  }[];
11
11
  handlers: {
package/dist/cli/index.js CHANGED
@@ -16,6 +16,15 @@
16
16
  * `@ankhorage/devtools`. Existing ESLint configuration is preserved during first migration as a
17
17
  * local extension before the canonical auto-detecting wrapper is installed.
18
18
  *
19
+ * APM owner releases opt in through `ankh.apm`. `ankh devtools apm sync .` binds a valid
20
+ * descriptor to the selected Changesets version. `ankh devtools apm validate .` checks a
21
+ * script-free tarball; `--allow-owner-code` explicitly permits the isolated executable probe.
22
+ * `--artifact <path>` retains the exact accepted tarball and `--expected-integrity <SRI>`
23
+ * rejects changed bytes. Owners provide `test:apm` for source-to-target and recovery fixtures.
24
+ * CI runs that owner suite and the packed check. Release rebuilds after versioning, validates,
25
+ * then publishes the retained archive with scripts disabled through the existing release job.
26
+ * Packages without metadata keep their existing Changesets publish path unchanged.
27
+ *
19
28
  * @readme
20
29
  */
21
30
  import { readFileSync } from 'node:fs';
@@ -1,6 +1,11 @@
1
+ import { sync } from './commands/apm/sync.js';
2
+ import { validate } from './commands/apm/validate.js';
1
3
  import { runExternalTool } from './runExternalTool.js';
2
4
  import { runRepositoryCommand, } from './runRepositoryCommand.js';
3
5
  export async function runProviderCommand(command, argv, context) {
6
+ if (command.kind === 'apm-release') {
7
+ return command.operation === 'sync' ? sync(argv, context) : validate(argv, context);
8
+ }
4
9
  if (command.kind === 'external') {
5
10
  return await runExternalTool(command, argv, {
6
11
  cwd: context.cwd,
@@ -0,0 +1,10 @@
1
+ /*** Parse release CLI input and report the same structured validation result on every entrypoint. */
2
+ export declare function runApmReleaseCommandAsync(operation: 'sync' | 'validate', argv: readonly string[], context: CommandContext): Promise<{
3
+ readonly exitCode: number;
4
+ }>;
5
+ interface CommandContext {
6
+ readonly cwd: string;
7
+ readonly writeStdout: (text: string) => void;
8
+ readonly writeStderr: (text: string) => void;
9
+ }
10
+ export {};
@@ -0,0 +1,51 @@
1
+ import { resolve } from 'node:path';
2
+ import { synchronizeApmReleaseDescriptorAsync } from '../../composition/synchronizeApmReleaseDescriptorAsync.js';
3
+ import { validatePackedApmReleaseAsync } from '../../composition/validatePackedApmReleaseAsync.js';
4
+ /*** Parse release CLI input and report the same structured validation result on every entrypoint. */
5
+ export async function runApmReleaseCommandAsync(operation, argv, context) {
6
+ try {
7
+ const { target, options } = parseArguments(operation, argv, context.cwd);
8
+ if (operation === 'sync') {
9
+ context.writeStdout(`${JSON.stringify({ changed: await synchronizeApmReleaseDescriptorAsync(target) })}\n`);
10
+ return { exitCode: 0 };
11
+ }
12
+ const result = await validatePackedApmReleaseAsync(target, options);
13
+ context.writeStdout(`${JSON.stringify(result)}\n`);
14
+ return { exitCode: result.valid ? 0 : 1 };
15
+ }
16
+ catch (error) {
17
+ context.writeStderr(`${error instanceof Error ? error.message : 'APM release validation failed.'}\n`);
18
+ return { exitCode: 1 };
19
+ }
20
+ }
21
+ /*** Separate release argument validation from execution; reject duplicates and incomplete flags. */
22
+ function parseArguments(operation, argv, cwd) {
23
+ if (operation === 'sync' && argv.some((value) => value.startsWith('--')))
24
+ throw new Error('APM sync does not accept validation options.');
25
+ const artifactIndex = optionIndex(argv, '--artifact');
26
+ const integrityIndex = optionIndex(argv, '--expected-integrity');
27
+ const ignored = [artifactIndex, integrityIndex].flatMap((index) => index < 0 ? [] : [index, index + 1]);
28
+ const remaining = argv.filter((value, index) => !ignored.includes(index) && value !== '--allow-owner-code');
29
+ if (remaining.length > 1 || remaining.some((value) => value.startsWith('-')))
30
+ throw new Error('Expected one target directory and supported release options.');
31
+ const artifact = artifactIndex < 0 ? undefined : argv.at(artifactIndex + 1);
32
+ const integrity = integrityIndex < 0 ? undefined : argv.at(integrityIndex + 1);
33
+ return {
34
+ target: resolve(cwd, remaining.at(0) ?? '.'),
35
+ options: {
36
+ allowOwnerCode: argv.includes('--allow-owner-code'),
37
+ ...(artifact === undefined ? {} : { artifactPath: resolve(cwd, artifact) }),
38
+ ...(integrity === undefined ? {} : { expectedIntegrity: integrity }),
39
+ },
40
+ };
41
+ }
42
+ /*** Require a single option occurrence followed by a value. */
43
+ function optionIndex(argv, option) {
44
+ const index = argv.indexOf(option);
45
+ if (index < 0)
46
+ return index;
47
+ const value = argv.at(index + 1);
48
+ if (argv.lastIndexOf(option) !== index || value === undefined || value.startsWith('--'))
49
+ throw new Error(`Invalid value for ${option}.`);
50
+ return index;
51
+ }
@@ -0,0 +1,3 @@
1
+ import type { ApmReleaseDescriptorPort } from '../../../../types/apm-release-validation.js';
2
+ /*** Supply bounded owner-document reads and idempotent writes for release synchronization. */
3
+ export declare function createApmReleaseDescriptorPort(targetDirectory: string): ApmReleaseDescriptorPort;
@@ -0,0 +1,31 @@
1
+ import { readFile, realpath, writeFile } from 'node:fs/promises';
2
+ import { relative, resolve } from 'node:path';
3
+ import { resolvePathWithinRoot } from '@ankhorage/utility/node/path';
4
+ import { format } from 'prettier';
5
+ import { readApmReleaseManifestAsync } from './readApmReleaseManifestAsync.js';
6
+ /*** Supply bounded owner-document reads and idempotent writes for release synchronization. */
7
+ export function createApmReleaseDescriptorPort(targetDirectory) {
8
+ return {
9
+ readPackageAsync: () => readApmReleaseManifestAsync(resolve(targetDirectory, 'package.json')),
10
+ readDescriptorAsync: async (subpath) => readApmReleaseManifestAsync(await descriptorPathAsync(targetDirectory, subpath)),
11
+ writeDescriptorAsync: async (subpath, descriptor) => {
12
+ const path = await descriptorPathAsync(targetDirectory, subpath);
13
+ const before = await readFile(path, 'utf8');
14
+ const after = await format(JSON.stringify(descriptor), {
15
+ parser: 'json',
16
+ printWidth: 100,
17
+ tabWidth: 2,
18
+ });
19
+ if (before === after)
20
+ return false;
21
+ await writeFile(path, after, 'utf8');
22
+ return true;
23
+ },
24
+ };
25
+ }
26
+ /*** Prevent descriptor reads and writes from following paths or symlinks outside the owner root. */
27
+ async function descriptorPathAsync(target, subpath) {
28
+ const root = await realpath(target);
29
+ const resolved = await realpath(resolvePathWithinRoot(root, subpath));
30
+ return resolvePathWithinRoot(root, relative(root, resolved));
31
+ }
@@ -0,0 +1,3 @@
1
+ import type { ApmReleaseValidationOptions, ApmReleaseValidationResult } from '../../../../types/apm-release-validation.js';
2
+ /*** Validate the actual script-free packed candidate outside the checkout, retaining only accepted bytes. */
3
+ export declare function inspectPackedApmReleaseCandidateAsync(targetDirectory: string, options?: ApmReleaseValidationOptions): Promise<ApmReleaseValidationResult>;
@@ -0,0 +1,84 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { copyFile, mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { relative, resolve } from 'node:path';
7
+ import { promisify } from 'node:util';
8
+ import { validatePackageUpdateMetadata, validateUpdateDescriptor } from '@ankhorage/apm';
9
+ import { resolvePathWithinRoot } from '@ankhorage/utility/node/path';
10
+ import { validateApmReleaseCandidate } from '../../application/validateApmReleaseCandidate.js';
11
+ import { readApmReleaseMetadata } from '../../utils/readApmReleaseMetadata.js';
12
+ import { probePackedApmExtensionAsync } from './probePackedApmExtensionAsync.js';
13
+ import { readApmReleaseManifestAsync } from './readApmReleaseManifestAsync.js';
14
+ /*** Validate the actual script-free packed candidate outside the checkout, retaining only accepted bytes. */
15
+ export async function inspectPackedApmReleaseCandidateAsync(targetDirectory, options = {}) {
16
+ const root = await realpath(targetDirectory);
17
+ const temporaryRoot = await mkdtemp(resolve(tmpdir(), 'ankh-apm-release-'));
18
+ try {
19
+ const archive = resolve(temporaryRoot, 'candidate.tgz');
20
+ const packageRoot = resolve(temporaryRoot, 'package');
21
+ await packCandidateAsync(root, temporaryRoot, archive);
22
+ const candidate = await readPackedCandidateAsync(packageRoot, archive);
23
+ const result = await validatePackedCandidateAsync(candidate, packageRoot, root, options);
24
+ if (!result.valid || options.artifactPath === undefined)
25
+ return result;
26
+ const artifactPath = resolve(options.artifactPath);
27
+ if (artifactPath === archive)
28
+ throw new Error('Artifact output must survive temporary validation cleanup.');
29
+ await mkdir(resolve(artifactPath, '..'), { recursive: true });
30
+ await copyFile(archive, artifactPath, constants.COPYFILE_EXCL);
31
+ return { ...result, artifactPath };
32
+ }
33
+ finally {
34
+ await rm(temporaryRoot, { recursive: true, force: true });
35
+ }
36
+ }
37
+ /*** Build and extract only a script-free local package archive under a separate temporary root. */
38
+ async function packCandidateAsync(root, temporaryRoot, archive) {
39
+ await execFileAsync('bun', ['pm', 'pack', '--filename', archive, '--ignore-scripts', '--quiet'], {
40
+ cwd: root,
41
+ ...PROCESS_LIMITS,
42
+ });
43
+ const { stdout } = await execFileAsync('tar', ['-tzf', archive], PROCESS_LIMITS);
44
+ for (const entry of stdout.trim().split('\n')) {
45
+ if (!entry.startsWith('package/'))
46
+ throw new Error('Packed file is outside the package prefix.');
47
+ resolvePathWithinRoot(temporaryRoot, entry);
48
+ }
49
+ await execFileAsync('tar', ['-xzf', archive, '-C', temporaryRoot, '--no-same-owner', '--no-same-permissions'], PROCESS_LIMITS);
50
+ }
51
+ /*** Read owner metadata strictly from the archive, not the source-tree descriptor. */
52
+ async function readPackedCandidateAsync(packageRoot, archive) {
53
+ const integrity = `sha512-${createHash('sha512')
54
+ .update(await readFile(archive))
55
+ .digest('base64')}`;
56
+ const packageJson = await readApmReleaseManifestAsync(resolve(packageRoot, 'package.json'));
57
+ const discovery = validatePackageUpdateMetadata(readApmReleaseMetadata(packageJson));
58
+ if (!discovery.valid || discovery.metadata === undefined) {
59
+ return { packageJson, integrity, descriptor: undefined, descriptorSource: '' };
60
+ }
61
+ const descriptorPath = await realpath(resolvePathWithinRoot(packageRoot, discovery.metadata.descriptor));
62
+ resolvePathWithinRoot(packageRoot, relative(packageRoot, descriptorPath));
63
+ const descriptorSource = await readFile(descriptorPath, 'utf8');
64
+ const descriptor = JSON.parse(descriptorSource);
65
+ return { packageJson, descriptor, descriptorSource, integrity };
66
+ }
67
+ /*** Require valid static evidence before optionally loading the exact packed owner extension. */
68
+ async function validatePackedCandidateAsync(candidate, packageRoot, root, options) {
69
+ const validation = validateUpdateDescriptor({ descriptor: candidate.descriptor });
70
+ const staticResult = validateApmReleaseCandidate(candidate, options);
71
+ const onlyExtensionMissing = staticResult.blockers.every((blocker) => blocker.startsWith('protocol.extension-binding-mismatch:'));
72
+ const extension = validation.descriptor?.extension;
73
+ return extension !== undefined && validation.valid && onlyExtensionMissing
74
+ ? probePackedApmExtensionAsync({
75
+ candidate,
76
+ packageRoot,
77
+ dependencyRoot: resolve(root, 'node_modules'),
78
+ subpath: extension.export,
79
+ options,
80
+ })
81
+ : staticResult;
82
+ }
83
+ const execFileAsync = promisify(execFile);
84
+ const PROCESS_LIMITS = { timeout: 60_000, maxBuffer: 4 * 1024 * 1024 };
@@ -0,0 +1,11 @@
1
+ import type { ApmPackedReleaseCandidate, ApmReleaseValidationOptions, ApmReleaseValidationResult } from '../../../../types/apm-release-validation.js';
2
+ /*** Load the native public ESM export in a bounded child process only after explicit owner-code consent. */
3
+ export declare function probePackedApmExtensionAsync(input: ProbeInput): Promise<ApmReleaseValidationResult>;
4
+ interface ProbeInput {
5
+ readonly candidate: ApmPackedReleaseCandidate;
6
+ readonly packageRoot: string;
7
+ readonly dependencyRoot: string;
8
+ readonly subpath: string;
9
+ readonly options: ApmReleaseValidationOptions;
10
+ }
11
+ export {};
@@ -0,0 +1,82 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile, symlink, writeFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { isRecord } from '@ankhorage/utility/object';
6
+ /*** Load the native public ESM export in a bounded child process only after explicit owner-code consent. */
7
+ export async function probePackedApmExtensionAsync(input) {
8
+ if (input.options.allowOwnerCode !== true) {
9
+ return {
10
+ applicable: true,
11
+ valid: false,
12
+ integrity: input.candidate.integrity,
13
+ blockers: [
14
+ 'release.owner-code-consent: Executable owner validation requires --allow-owner-code.',
15
+ ],
16
+ };
17
+ }
18
+ const { probePath, resultPath } = await prepareProbeAsync(input);
19
+ try {
20
+ await execFileAsync(process.execPath, [probePath], {
21
+ cwd: input.packageRoot,
22
+ timeout: 30_000,
23
+ maxBuffer: 1024 * 1024,
24
+ env: {
25
+ PATH: process.env.PATH,
26
+ SYSTEMROOT: process.env.SYSTEMROOT,
27
+ HOME: input.packageRoot,
28
+ TMPDIR: input.packageRoot,
29
+ },
30
+ });
31
+ return await readProbeResultAsync(input.candidate, resultPath);
32
+ }
33
+ catch {
34
+ return {
35
+ applicable: true,
36
+ valid: false,
37
+ integrity: input.candidate.integrity,
38
+ blockers: [
39
+ 'release.extension-load: Packed public export failed to load or validate in the owner probe.',
40
+ ],
41
+ };
42
+ }
43
+ }
44
+ /*** Materialize a probe that uses native self-reference export resolution in the extracted package. */
45
+ async function prepareProbeAsync(input) {
46
+ const requestPath = resolve(input.packageRoot, '.apm-release-request.json');
47
+ const resultPath = resolve(input.packageRoot, '.apm-release-result.json');
48
+ const probePath = resolve(input.packageRoot, '.apm-release-probe.mjs');
49
+ const specifier = `${String(input.candidate.packageJson.name)}${input.subpath.slice(1)}`;
50
+ await symlink(input.dependencyRoot, resolve(input.packageRoot, 'node_modules'), 'dir');
51
+ await writeFile(requestPath, JSON.stringify({ candidate: input.candidate, options: input.options }));
52
+ const validatorUrl = new URL('../../application/validateApmReleaseCandidate.js', import.meta.url)
53
+ .href;
54
+ await writeFile(probePath, [
55
+ "import { readFile, writeFile } from 'node:fs/promises';",
56
+ `import { validateApmReleaseCandidate } from ${JSON.stringify(validatorUrl)};`,
57
+ `const request = JSON.parse(await readFile(${JSON.stringify(requestPath)}, 'utf8'));`,
58
+ `const loaded = await import(${JSON.stringify(specifier)});`,
59
+ 'const result = validateApmReleaseCandidate({ ...request.candidate, extension: loaded.default }, request.options);',
60
+ `await writeFile(${JSON.stringify(resultPath)}, JSON.stringify(result));`,
61
+ ].join('\n'));
62
+ return { probePath, resultPath };
63
+ }
64
+ /*** Validate child-process response data before exposing a structured report. */
65
+ async function readProbeResultAsync(candidate, resultPath) {
66
+ const result = JSON.parse(await readFile(resultPath, 'utf8'));
67
+ if (!isRecord(result) ||
68
+ typeof result.valid !== 'boolean' ||
69
+ !Array.isArray(result.blockers) ||
70
+ !result.blockers.every((value) => typeof value === 'string')) {
71
+ throw new Error('Invalid owner probe response.');
72
+ }
73
+ return {
74
+ applicable: true,
75
+ valid: result.valid,
76
+ integrity: candidate.integrity,
77
+ packageName: String(candidate.packageJson.name),
78
+ packageVersion: String(candidate.packageJson.version),
79
+ blockers: result.blockers,
80
+ };
81
+ }
82
+ const execFileAsync = promisify(execFile);
@@ -0,0 +1,2 @@
1
+ /*** Read a release-owned JSON object while rejecting malformed documents. */
2
+ export declare function readApmReleaseManifestAsync(filePath: string): Promise<Readonly<Record<string, unknown>>>;
@@ -0,0 +1,9 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { isRecord } from '@ankhorage/utility/object';
3
+ /*** Read a release-owned JSON object while rejecting malformed documents. */
4
+ export async function readApmReleaseManifestAsync(filePath) {
5
+ const value = JSON.parse(await readFile(filePath, 'utf8'));
6
+ if (!isRecord(value))
7
+ throw new Error('APM release document must contain a JSON object.');
8
+ return value;
9
+ }
@@ -0,0 +1,2 @@
1
+ /*** Use Devtools' own built CLI for self-validation and the installed dependency binary in consumers. */
2
+ export declare function resolveApmReleaseCommandAsync(targetDirectory: string): Promise<string>;
@@ -0,0 +1,18 @@
1
+ import { resolve } from 'node:path';
2
+ import { readApmReleaseManifestAsync } from './readApmReleaseManifestAsync.js';
3
+ /*** Use Devtools' own built CLI for self-validation and the installed dependency binary in consumers. */
4
+ export async function resolveApmReleaseCommandAsync(targetDirectory) {
5
+ try {
6
+ const manifest = await readApmReleaseManifestAsync(resolve(targetDirectory, 'package.json'));
7
+ return manifest.name === '@ankhorage/devtools'
8
+ ? 'node ./dist/cli/bin/apm-release.js'
9
+ : CONSUMER_COMMAND;
10
+ }
11
+ catch (error) {
12
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
13
+ return CONSUMER_COMMAND;
14
+ }
15
+ throw error;
16
+ }
17
+ }
18
+ const CONSUMER_COMMAND = './node_modules/.bin/ankhorage-apm-release';
@@ -0,0 +1,3 @@
1
+ import type { ApmReleaseDescriptorPort } from '../../../types/apm-release-validation.js';
2
+ /*** Bind valid owner metadata to the selected candidate version; never silently skip malformed opt-in. */
3
+ export declare function synchronizeApmReleaseMetadataAsync(port: ApmReleaseDescriptorPort): Promise<boolean>;
@@ -0,0 +1,29 @@
1
+ import { validatePackageUpdateMetadata, validateUpdateDescriptor } from '@ankhorage/apm';
2
+ import { isRecord } from '@ankhorage/utility/object';
3
+ import { readApmReleaseMetadata } from '../utils/readApmReleaseMetadata.js';
4
+ /*** Bind valid owner metadata to the selected candidate version; never silently skip malformed opt-in. */
5
+ export async function synchronizeApmReleaseMetadataAsync(port) {
6
+ const manifest = await port.readPackageAsync();
7
+ const metadata = readApmReleaseMetadata(manifest);
8
+ if (metadata === undefined)
9
+ return false;
10
+ const discovery = validatePackageUpdateMetadata(metadata);
11
+ if (!discovery.valid || discovery.metadata === undefined)
12
+ throw new Error('Invalid APM discovery metadata.');
13
+ const descriptor = await port.readDescriptorAsync(discovery.metadata.descriptor);
14
+ const { name, version } = manifest;
15
+ if (typeof name !== 'string' ||
16
+ typeof version !== 'string' ||
17
+ !isRecord(descriptor.owner) ||
18
+ descriptor.owner.name !== name) {
19
+ throw new Error('APM release descriptor must belong to the package being versioned.');
20
+ }
21
+ const synchronized = { ...descriptor, owner: { ...descriptor.owner, version } };
22
+ const validation = validateUpdateDescriptor({
23
+ descriptor: synchronized,
24
+ expectedOwner: { name, version },
25
+ });
26
+ if (!validation.valid)
27
+ throw new Error(validation.blockers.map(({ code, reason }) => `${code}: ${reason}`).join('\n'));
28
+ return port.writeDescriptorAsync(discovery.metadata.descriptor, synchronized);
29
+ }
@@ -0,0 +1,11 @@
1
+ import type { ApmPackedReleaseCandidate, ApmReleaseValidationOptions, ApmReleaseValidationResult } from '../../../types/apm-release-validation.js';
2
+ /*** Validate packed owner evidence using the released APM protocol, never a copied schema.
3
+ * Opt-in packages declare ankh.apm with protocolVersion and a package-relative descriptor.
4
+ * No metadata means not applicable, not a claim that no migration is needed. The descriptor
5
+ * declares supported no-migration, required migration, or unsupported/manual history explicitly.
6
+ * Previous descriptors enforce immutable migration checksums and related descriptors validate
7
+ * cross-owner prerequisites. Package-owned source-to-target tests must also cover skipped versions,
8
+ * idempotency, interruption/recovery, and unsupported historical states.
9
+ * @readme
10
+ */
11
+ export declare function validateApmReleaseCandidate(candidate: ApmPackedReleaseCandidate, options?: ApmReleaseValidationOptions): ApmReleaseValidationResult;
@@ -0,0 +1,76 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { validatePackageUpdateMetadata, validateUpdateDescriptor, validateUpdateExtensionBinding, validateUpdateExtensionCapabilities, } from '@ankhorage/apm';
3
+ import { readApmReleaseMetadata } from '../utils/readApmReleaseMetadata.js';
4
+ /*** Validate packed owner evidence using the released APM protocol, never a copied schema.
5
+ * Opt-in packages declare ankh.apm with protocolVersion and a package-relative descriptor.
6
+ * No metadata means not applicable, not a claim that no migration is needed. The descriptor
7
+ * declares supported no-migration, required migration, or unsupported/manual history explicitly.
8
+ * Previous descriptors enforce immutable migration checksums and related descriptors validate
9
+ * cross-owner prerequisites. Package-owned source-to-target tests must also cover skipped versions,
10
+ * idempotency, interruption/recovery, and unsupported historical states.
11
+ * @readme
12
+ */
13
+ export function validateApmReleaseCandidate(candidate, options = {}) {
14
+ const { name, version } = candidate.packageJson;
15
+ const identity = {
16
+ ...(typeof name === 'string' ? { packageName: name } : {}),
17
+ ...(typeof version === 'string' ? { packageVersion: version } : {}),
18
+ integrity: candidate.integrity,
19
+ };
20
+ const metadata = readApmReleaseMetadata(candidate.packageJson);
21
+ const integrityBlockers = options.expectedIntegrity !== undefined && options.expectedIntegrity !== candidate.integrity
22
+ ? ['release.integrity-mismatch: Packed bytes differ from the selected release artifact.']
23
+ : [];
24
+ if (metadata === undefined) {
25
+ return {
26
+ ...identity,
27
+ applicable: false,
28
+ valid: integrityBlockers.length === 0,
29
+ blockers: integrityBlockers,
30
+ };
31
+ }
32
+ const metadataValidation = validatePackageUpdateMetadata(metadata);
33
+ if (typeof name !== 'string' || !name || typeof version !== 'string' || !version) {
34
+ return result(identity, ['release.identity: Package must define name and exact version.']);
35
+ }
36
+ if (!metadataValidation.valid)
37
+ return result(identity, [...integrityBlockers, ...messages(metadataValidation.blockers)]);
38
+ const validation = validateUpdateDescriptor({
39
+ descriptor: candidate.descriptor,
40
+ expectedOwner: { name, version },
41
+ previousDescriptors: options.previousDescriptors,
42
+ relatedDescriptors: options.relatedDescriptors,
43
+ });
44
+ if (!validation.valid || validation.descriptor === undefined || integrityBlockers.length > 0) {
45
+ return result(identity, [...integrityBlockers, ...messages(validation.blockers)]);
46
+ }
47
+ return result(identity, extensionBlockers(candidate, validation.descriptor));
48
+ }
49
+ /*** Validate executable binding and required handlers after static metadata has passed. */
50
+ function extensionBlockers(candidate, descriptor) {
51
+ if (descriptor.extension === undefined)
52
+ return [];
53
+ const artifact = {
54
+ role: 'target',
55
+ packageName: descriptor.owner.name,
56
+ version: descriptor.owner.version,
57
+ integrity: candidate.integrity,
58
+ descriptorDigest: createHash('sha256').update(candidate.descriptorSource).digest('hex'),
59
+ };
60
+ if (!isBoundExtension(artifact, candidate.extension)) {
61
+ return messages(validateUpdateExtensionBinding(artifact, candidate.extension));
62
+ }
63
+ return messages(validateUpdateExtensionCapabilities(descriptor, artifact, candidate.extension));
64
+ }
65
+ /*** Narrow unknown executable evidence only after the canonical runtime validator accepts it. */
66
+ function isBoundExtension(artifact, value) {
67
+ return validateUpdateExtensionBinding(artifact, value).length === 0;
68
+ }
69
+ /*** Preserve structured candidate identity and canonical blocker reasons. */
70
+ function result(identity, blockers) {
71
+ return { ...identity, applicable: true, valid: blockers.length === 0, blockers };
72
+ }
73
+ /*** Render the protocol's own diagnostics without reinterpreting their semantics. */
74
+ function messages(blockers) {
75
+ return blockers.map(({ code, reason }) => `${code}: ${reason}`);
76
+ }
@@ -0,0 +1,2 @@
1
+ /*** Compose descriptor version synchronization with the bounded Node filesystem adapter. */
2
+ export declare function synchronizeApmReleaseDescriptorAsync(targetDirectory: string): Promise<boolean>;
@@ -0,0 +1,6 @@
1
+ import { createApmReleaseDescriptorPort } from '../adapters/outbound/createApmReleaseDescriptorPort.js';
2
+ import { synchronizeApmReleaseMetadataAsync } from '../application/synchronizeApmReleaseMetadataAsync.js';
3
+ /*** Compose descriptor version synchronization with the bounded Node filesystem adapter. */
4
+ export async function synchronizeApmReleaseDescriptorAsync(targetDirectory) {
5
+ return synchronizeApmReleaseMetadataAsync(createApmReleaseDescriptorPort(targetDirectory));
6
+ }
@@ -0,0 +1,12 @@
1
+ import type { ApmReleaseValidationOptions, ApmReleaseValidationResult } from '../../../types/apm-release-validation.js';
2
+ /*** Validate script-free publish bytes and optionally retain that exact accepted archive.
3
+ * Release order is APM protocol, package-owner metadata/code, then consuming applications.
4
+ * Build after Changesets versioning and descriptor synchronization. Execute package-owned
5
+ * acceptance tests, then validate with explicit owner-code consent. The child probe uses the
6
+ * packed package's native public export resolution and installed dependencies, not omitted source.
7
+ * Consent permits trusted executable code; a child process is not a security sandbox.
8
+ * An artifactPath retains accepted bytes exclusively, so publication need not repack or rerun
9
+ * lifecycle scripts. expectedIntegrity rejects a stale candidate before any owner code loads.
10
+ * @readme
11
+ */
12
+ export declare function validatePackedApmReleaseAsync(targetDirectory: string, options?: ApmReleaseValidationOptions): Promise<ApmReleaseValidationResult>;
@@ -0,0 +1,14 @@
1
+ import { inspectPackedApmReleaseCandidateAsync } from '../adapters/outbound/inspectPackedApmReleaseCandidateAsync.js';
2
+ /*** Validate script-free publish bytes and optionally retain that exact accepted archive.
3
+ * Release order is APM protocol, package-owner metadata/code, then consuming applications.
4
+ * Build after Changesets versioning and descriptor synchronization. Execute package-owned
5
+ * acceptance tests, then validate with explicit owner-code consent. The child probe uses the
6
+ * packed package's native public export resolution and installed dependencies, not omitted source.
7
+ * Consent permits trusted executable code; a child process is not a security sandbox.
8
+ * An artifactPath retains accepted bytes exclusively, so publication need not repack or rerun
9
+ * lifecycle scripts. expectedIntegrity rejects a stale candidate before any owner code loads.
10
+ * @readme
11
+ */
12
+ export async function validatePackedApmReleaseAsync(targetDirectory, options = {}) {
13
+ return inspectPackedApmReleaseCandidateAsync(targetDirectory, options);
14
+ }
@@ -0,0 +1,2 @@
1
+ /*** Read opt-in owner metadata without copying the APM discovery schema. */
2
+ export declare function readApmReleaseMetadata(manifest: Readonly<Record<string, unknown>>): unknown;
@@ -0,0 +1,6 @@
1
+ import { isRecord, readOwnProperty } from '@ankhorage/utility/object';
2
+ /*** Read opt-in owner metadata without copying the APM discovery schema. */
3
+ export function readApmReleaseMetadata(manifest) {
4
+ const ankh = readOwnProperty(manifest, 'ankh');
5
+ return isRecord(ankh) ? readOwnProperty(ankh, 'apm') : undefined;
6
+ }
@@ -41,6 +41,13 @@ jobs:
41
41
  echo "No build script found; skipping."
42
42
  fi
43
43
 
44
+ - name: Validate opted-in APM owner release
45
+ run: |
46
+ if node -e "const p=require('./package.json'); process.exit(p.ankh?.apm === undefined ? 1 : 0)"; then
47
+ bun run test:apm
48
+ __ANKH_APM_RELEASE_COMMAND__ validate . --allow-owner-code
49
+ fi
50
+
44
51
  - name: Validate Ankhorage repository
45
52
  run: bunx @ankhorage/ankh doctor validate .
46
53
 
@@ -73,6 +73,9 @@ jobs:
73
73
 
74
74
  before_version="$(node -p "require('./package.json').version")"
75
75
  __ANKH_CHANGESETS_VERSION_COMMAND__
76
+ if node -e "const p=require('./package.json'); process.exit(p.ankh?.apm === undefined ? 1 : 0)"; then
77
+ __ANKH_APM_RELEASE_COMMAND__ sync .
78
+ fi
76
79
  after_version="$(node -p "require('./package.json').version")"
77
80
 
78
81
  if git diff --quiet; then
@@ -100,7 +103,30 @@ jobs:
100
103
 
101
104
  - name: Publish unpublished packages
102
105
  if: hashFiles('.changeset/config.json') != ''
103
- run: __ANKH_CHANGESETS_PUBLISH_COMMAND__
106
+ run: |
107
+ if ! node -e "const p=require('./package.json'); process.exit(p.ankh?.apm === undefined ? 1 : 0)"; then
108
+ __ANKH_CHANGESETS_PUBLISH_COMMAND__
109
+ exit 0
110
+ fi
111
+
112
+ package_name="$(node -p "require('./package.json').name")"
113
+ package_version="$(node -p "require('./package.json').version")"
114
+ registry_evidence="${RUNNER_TEMP}/apm-registry.json"
115
+ if npm view "${package_name}@${package_version}" version --json > "$registry_evidence"; then
116
+ echo "Exact APM owner version is already published."
117
+ else
118
+ # Authentication/network errors must never be treated as an unpublished version.
119
+ node -e "const p=require(process.argv[1]); if(p.error?.code !== 'E404') process.exit(1)" "$registry_evidence"
120
+ bun install --frozen-lockfile --ignore-scripts
121
+ bun run build
122
+ bun run test:apm
123
+ artifact="$(mktemp -d "${RUNNER_TEMP}/apm-publish.XXXXXX")/candidate.tgz"
124
+ __ANKH_APM_RELEASE_COMMAND__ validate . --allow-owner-code --artifact "$artifact"
125
+ npm publish "$artifact" --ignore-scripts
126
+ fi
127
+ if ! git rev-parse -q --verify "refs/tags/v${package_version}" >/dev/null; then
128
+ git tag "v${package_version}"
129
+ fi
104
130
 
105
131
  - name: Finalize Changesets v3 tags and GitHub releases
106
132
  if: hashFiles('.changeset/config.json') != ''
@@ -1,5 +1,6 @@
1
1
  import { access } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { resolveApmReleaseCommandAsync } from '../../features/apm-release-validation/adapters/outbound/resolveApmReleaseCommandAsync.js';
3
4
  import { bunRuntimePolicy, nodeRuntimePolicy } from '../../policy/bunRuntimePolicy.js';
4
5
  import { renderRenovateWorkflowAsync } from './renderRenovateWorkflowAsync.js';
5
6
  import { renderWorkflowAsync } from './renderWorkflowAsync.js';
@@ -30,7 +31,8 @@ function createWorkflowDefinition(relativePath, sourcePath) {
30
31
  const sourceUrl = new URL(sourcePath, import.meta.url);
31
32
  return {
32
33
  relativePath,
33
- render: async () => await renderWorkflowAsync(sourceUrl, {
34
+ render: async (targetDirectory) => await renderWorkflowAsync(sourceUrl, {
35
+ apmReleaseCommand: await resolveApmReleaseCommandAsync(targetDirectory),
34
36
  bunVersion: bunRuntimePolicy.version,
35
37
  nodeVersion: nodeRuntimePolicy.setupVersion,
36
38
  }),
@@ -42,6 +44,7 @@ function createRenovateWorkflowDefinition() {
42
44
  return {
43
45
  relativePath: '.github/workflows/renovate.yml',
44
46
  render: async (targetDirectory) => await renderRenovateWorkflowAsync(sourceUrl, targetDirectory, {
47
+ apmReleaseCommand: await resolveApmReleaseCommandAsync(targetDirectory),
45
48
  bunVersion: bunRuntimePolicy.version,
46
49
  nodeVersion: nodeRuntimePolicy.setupVersion,
47
50
  }),
@@ -1,6 +1,7 @@
1
1
  export interface WorkflowPolicy {
2
2
  readonly bunVersion: string;
3
3
  readonly nodeVersion: string;
4
+ readonly apmReleaseCommand?: string;
4
5
  }
5
6
  /*** Renders one workflow template with the canonical runtime and Changesets policy. */
6
7
  export declare function renderWorkflowAsync(sourceUrl: URL, policy: WorkflowPolicy): Promise<string>;
@@ -8,6 +8,7 @@ export async function renderWorkflowAsync(sourceUrl, policy) {
8
8
  .replaceAll(CHANGESETS_PUBLISH_COMMAND_TOKEN, changesetsPolicy.workflowCommands.publish)
9
9
  .replaceAll(CHANGESETS_STATUS_COMMAND_TOKEN, changesetsPolicy.workflowCommands.status)
10
10
  .replaceAll(CHANGESETS_VERSION_COMMAND_TOKEN, changesetsPolicy.workflowCommands.version)
11
+ .replaceAll('__ANKH_APM_RELEASE_COMMAND__', policy.apmReleaseCommand ?? './node_modules/.bin/ankhorage-apm-release')
11
12
  .replaceAll(NODE_VERSION_TOKEN, policy.nodeVersion);
12
13
  }
13
14
  const BUN_VERSION_TOKEN = '__ANKH_BUN_VERSION__';
@@ -0,0 +1,33 @@
1
+ import type { ApmUpdateDescriptor } from '@ankhorage/apm/types';
2
+ /*** Packed bytes and loaded owner evidence supplied to the canonical APM validators. */
3
+ export interface ApmPackedReleaseCandidate {
4
+ readonly packageJson: Readonly<Record<string, unknown>>;
5
+ readonly descriptor: unknown;
6
+ readonly descriptorSource: string;
7
+ readonly integrity: string;
8
+ readonly extension?: unknown;
9
+ }
10
+ /*** Explicit release-validation inputs; owner code is never trusted by default. */
11
+ export interface ApmReleaseValidationOptions {
12
+ readonly allowOwnerCode?: boolean;
13
+ readonly artifactPath?: string;
14
+ readonly expectedIntegrity?: string;
15
+ readonly previousDescriptors?: readonly ApmUpdateDescriptor[];
16
+ readonly relatedDescriptors?: readonly ApmUpdateDescriptor[];
17
+ }
18
+ /*** Evidence for one exact packed release; inapplicable never means migration-safe. */
19
+ export interface ApmReleaseValidationResult {
20
+ readonly applicable: boolean;
21
+ readonly valid: boolean;
22
+ readonly packageName?: string;
23
+ readonly packageVersion?: string;
24
+ readonly integrity?: string;
25
+ readonly artifactPath?: string;
26
+ readonly blockers: readonly string[];
27
+ }
28
+ /*** Filesystem capabilities required by descriptor synchronization, independent of transport. */
29
+ export interface ApmReleaseDescriptorPort {
30
+ readonly readPackageAsync: () => Promise<Readonly<Record<string, unknown>>>;
31
+ readonly readDescriptorAsync: (subpath: string) => Promise<Readonly<Record<string, unknown>>>;
32
+ readonly writeDescriptorAsync: (subpath: string, descriptor: Readonly<Record<string, unknown>>) => Promise<boolean>;
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export type { ApmPackedReleaseCandidate, ApmReleaseValidationOptions, ApmReleaseValidationResult, } from './apm-release-validation.js';
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ankhorage/devtools",
3
- "version": "1.14.8",
3
+ "version": "1.15.0",
4
4
  "description": "Shared tooling, repository automation, runtime policies, and agent standards for Ankhorage TypeScript projects",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/ankhorage/devtools#readme",
@@ -19,6 +19,8 @@
19
19
  "category": "devtools",
20
20
  "provider": "./dist/cli/index.js",
21
21
  "capabilities": [
22
+ "devtools.apm.sync",
23
+ "devtools.apm.validate",
22
24
  "devtools.changeset",
23
25
  "devtools.lint",
24
26
  "devtools.format",
@@ -60,7 +62,8 @@
60
62
  "ankhorage-changeset": "./dist/cli/bin/changeset.js",
61
63
  "ankhorage-eslint": "./dist/cli/bin/eslint.js",
62
64
  "ankhorage-knip": "./dist/cli/bin/knip.js",
63
- "ankhorage-prettier": "./dist/cli/bin/prettier.js"
65
+ "ankhorage-prettier": "./dist/cli/bin/prettier.js",
66
+ "ankhorage-apm-release": "./dist/cli/bin/apm-release.js"
64
67
  },
65
68
  "exports": {
66
69
  "./cli": {
@@ -83,6 +86,14 @@
83
86
  "import": "./dist/tools/prettier/index.cjs",
84
87
  "require": "./dist/tools/prettier/index.cjs",
85
88
  "default": "./dist/tools/prettier/index.cjs"
89
+ },
90
+ "./apm-release": {
91
+ "types": "./dist/apmRelease.d.ts",
92
+ "import": "./dist/apmRelease.js"
93
+ },
94
+ "./types": {
95
+ "types": "./dist/types/public.d.ts",
96
+ "import": "./dist/types/public.js"
86
97
  }
87
98
  },
88
99
  "files": [
@@ -93,7 +104,7 @@
93
104
  ],
94
105
  "scripts": {
95
106
  "build": "rm -rf dist tsconfig.tsbuildinfo && tsc && mkdir -p dist/tools/prettier dist/tools/workflows dist/tools/vscode dist/tools/skills && cp src/tools/prettier/index.cjs dist/tools/prettier/index.cjs && cp -R src/tools/workflows/files dist/tools/workflows/files && cp -R src/tools/vscode/files dist/tools/vscode/files && cp -R src/tools/skills/assets dist/tools/skills/assets",
96
- "typecheck": "bun x tsc --noEmit -p tsconfig.test.json",
107
+ "typecheck": "bun run check-types",
97
108
  "doctor": "ankhorage-doctor validate .",
98
109
  "knip:check": "knip",
99
110
  "lint": "eslint .",
@@ -105,9 +116,11 @@
105
116
  "test": "bun test",
106
117
  "changeset": "bun src/cli/bin/changeset.ts",
107
118
  "changeset:status": "bun src/cli/bin/changeset.ts status --since=origin/main",
108
- "version-packages": "bun src/cli/bin/changeset.ts version"
119
+ "version-packages": "bun src/cli/bin/changeset.ts version",
120
+ "check-types": "bun x tsc --noEmit -p tsconfig.test.json"
109
121
  },
110
122
  "dependencies": {
123
+ "@ankhorage/apm": "^0.6.0",
111
124
  "@ankhorage/project-detector": "^0.1.0",
112
125
  "@ankhorage/utility": "^0.5.1",
113
126
  "@changesets/cli": "^3.0.2",