@sdd-method/maintain-cli 0.1.0-rc.0 → 0.1.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.
@@ -18,6 +18,16 @@ import { DAGGER_VERSION, MODULE_CALL, MODULE_DIR, moduleSha256, renderModuleFile
18
18
  export const CI_BINDINGS = ["github", "gitlab", "jenkins"];
19
19
  /** M-ADR 0219 §8.2 (ruled 2026-10-16): what every SDD has before it chooses. */
20
20
  export const DEFAULT_CI_BINDING = "github";
21
+ /**
22
+ * Runner labels (sync --ci-runner): where the host runs the job. One label is a word of letters,
23
+ * digits, '.', '_' or '-', so a label can never inject YAML or Groovy into a wrapper.
24
+ */
25
+ export const CI_RUNNER_DEFAULT = "default";
26
+ const RUNNER_LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
27
+ export function parseCiRunner(s) {
28
+ const labels = s.split(",").map((l) => l.trim());
29
+ return labels.length > 0 && labels.every((l) => RUNNER_LABEL.test(l)) ? labels : null;
30
+ }
21
31
  export function isCiBinding(s) {
22
32
  return CI_BINDINGS.includes(s);
23
33
  }
@@ -42,13 +52,14 @@ function header(comment, profile) {
42
52
  // GitLab: a Docker-in-Docker service, the shape GitLab documents for Dagger. The runner must allow
43
53
  // privileged containers; that is the adopter's infrastructure (M-ADR 0219 §4.5). No `stage:`, so
44
54
  // the job lands in the default `test` stage whatever stages the adopter declares around it.
45
- function gitlabBlock(profile) {
55
+ function gitlabBlock(profile, runner) {
46
56
  return [
47
57
  HASH.begin,
48
58
  ...header("#", profile),
49
59
  "",
50
60
  "method-validation:",
51
61
  " image: docker:27.3.1",
62
+ ...(runner !== undefined && runner.length > 0 ? [` tags: [${runner.join(", ")}]`] : []),
52
63
  " services:",
53
64
  " - docker:27.3.1-dind",
54
65
  " variables:",
@@ -70,13 +81,13 @@ function gitlabBlock(profile) {
70
81
  // Jenkins: a declarative pipeline at its own path, so it never collides with an adopter's
71
82
  // Jenkinsfile; the job's Script Path points at it. The agent must reach a Docker daemon. Dagger is
72
83
  // installed outside the workspace, so the binary is not uploaded with --source.
73
- function jenkinsBlock(profile) {
84
+ function jenkinsBlock(profile, runner) {
74
85
  return [
75
86
  SLASH.begin,
76
87
  ...header("//", profile),
77
88
  "",
78
89
  "pipeline {",
79
- " agent any",
90
+ runner !== undefined && runner.length > 0 ? ` agent { label '${runner.join(" && ")}' }` : " agent any",
80
91
  " stages {",
81
92
  " stage('Method Validation') {",
82
93
  " steps {",
@@ -100,13 +111,13 @@ export const HOST_WRAPPERS = {
100
111
  relPath: ".gitlab-ci.yml",
101
112
  markers: HASH,
102
113
  block: gitlabBlock,
103
- file: (p) => gitlabBlock(p) + "\n\n# Adopter-specific jobs go below, outside the method-managed markers.\n",
114
+ file: (p, r) => gitlabBlock(p, r) + "\n\n# Adopter-specific jobs go below, outside the method-managed markers.\n",
104
115
  },
105
116
  jenkins: {
106
117
  relPath: ".jenkins/method-validation.Jenkinsfile",
107
118
  markers: SLASH,
108
119
  block: jenkinsBlock,
109
- file: (p) => jenkinsBlock(p) + "\n",
120
+ file: (p, r) => jenkinsBlock(p, r) + "\n",
110
121
  },
111
122
  };
112
123
  /** sha256 of a host file's method-managed block (begin marker through end marker), or null without one. */
@@ -128,7 +139,7 @@ export async function syncCiBinding(o) {
128
139
  // 1. The host wrapper.
129
140
  const wrapperAbs = join(o.targetRepo, host.relPath);
130
141
  const existing = await tryRead(wrapperAbs);
131
- const block = host.block(o.profile);
142
+ const block = host.block(o.profile, o.runner);
132
143
  const drifted = existing !== null && detectBlockDrift(existing, block, host.markers);
133
144
  const recorded = sameBinding ? o.prevLock?.ciWorkflowBlockSha256 : undefined;
134
145
  const pristine = existing !== null && recorded !== undefined && blockSha256(existing, host.markers) === recorded;
@@ -159,7 +170,7 @@ export async function syncCiBinding(o) {
159
170
  if (o.checkOnly || failures.length > 0) {
160
171
  return { failures, log, wrapperSha: null, moduleSha: moduleSha256(onDisk) };
161
172
  }
162
- const written = await writeMarkedBlockFile(wrapperAbs, existing === null ? host.file(o.profile) : block, host.markers, o.allowOverwrite);
173
+ const written = await writeMarkedBlockFile(wrapperAbs, existing === null ? host.file(o.profile, o.runner) : block, host.markers, o.allowOverwrite);
163
174
  if (written.changed)
164
175
  log.push(`${existing === null ? "Wrote" : "Refreshed"} ${host.relPath}.`);
165
176
  if (!matches) {
@@ -16,8 +16,8 @@ export const BLOCK_END = "# method-managed: end";
16
16
  * Full file content (block + adopter-extension footer). Used by
17
17
  * `sdd-cli init` to write a fresh workflow.
18
18
  */
19
- export function renderMethodValidationWorkflow(profile) {
20
- const methodBlock = buildMethodManagedBlock(profile);
19
+ export function renderMethodValidationWorkflow(profile, runner) {
20
+ const methodBlock = buildMethodManagedBlock(profile, runner);
21
21
  return methodBlock + "\n\n# Adopter-specific jobs go in a separate workflow file\n# (e.g. .github/workflows/adopter-validation.yml) — see\n# docs/method/methods/ci-baseline-guide.md.\n";
22
22
  }
23
23
  /**
@@ -26,10 +26,19 @@ export function renderMethodValidationWorkflow(profile) {
26
26
  * `writeMarkedBlockFile` so adopter content outside the markers is
27
27
  * preserved verbatim.
28
28
  */
29
- export function renderMethodManagedWorkflowBlock(profile) {
30
- return buildMethodManagedBlock(profile);
29
+ export function renderMethodManagedWorkflowBlock(profile, runner) {
30
+ return buildMethodManagedBlock(profile, runner);
31
31
  }
32
- function buildMethodManagedBlock(profile) {
32
+ /**
33
+ * `runs-on`: GitHub's hosted runner unless the SDD chose labels (sync --ci-runner), e.g. a
34
+ * self-hosted runner, where the Dagger engine and its cache persist between runs.
35
+ */
36
+ function runsOn(runner) {
37
+ if (runner === undefined || runner.length === 0)
38
+ return "ubuntu-latest";
39
+ return runner.length === 1 ? runner[0] : `[${runner.join(", ")}]`;
40
+ }
41
+ function buildMethodManagedBlock(profile, runner) {
33
42
  return [
34
43
  BLOCK_BEGIN,
35
44
  "# Method Validation per M-ADR 0129, run by the method's Dagger module (M-ADR 0219).",
@@ -63,7 +72,7 @@ function buildMethodManagedBlock(profile) {
63
72
  // ONE job: GitHub bills each job rounded UP to a whole minute (adopter measurement, 2026-10-12).
64
73
  " method-validation:",
65
74
  " name: Method Validation",
66
- " runs-on: ubuntu-latest",
75
+ ` runs-on: ${runsOn(runner)}`,
67
76
  " steps:",
68
77
  " - uses: actions/checkout@v4",
69
78
  " - name: Install Dagger",
@@ -63,6 +63,7 @@ export function serialiseLockFile(lock) {
63
63
  `method_repo: ${lock.methodRepo}`,
64
64
  `lock_version: ${lock.lockVersion}`,
65
65
  ...(lock.ciBinding !== undefined ? [`ci_binding: ${lock.ciBinding}`] : []),
66
+ ...(lock.ciRunner !== undefined ? [`ci_runner: "${lock.ciRunner}"`] : []),
66
67
  ...(lock.ciWorkflowBlockSha256 !== undefined ? [`ci_workflow_block_sha256: "${lock.ciWorkflowBlockSha256}"`] : []),
67
68
  ...(lock.ciModuleSha256 !== undefined ? [`ci_module_sha256: "${lock.ciModuleSha256}"`] : []),
68
69
  "bundles:",
@@ -114,6 +115,7 @@ function parseVersioned(raw) {
114
115
  let lockVersion;
115
116
  let ciWorkflowBlockSha256;
116
117
  let ciBinding;
118
+ let ciRunner;
117
119
  let ciModuleSha256;
118
120
  const bundles = [];
119
121
  let inBundles = false;
@@ -180,6 +182,8 @@ function parseVersioned(raw) {
180
182
  ciWorkflowBlockSha256 = stripQuotes(value);
181
183
  else if (key === "ci_binding")
182
184
  ciBinding = stripQuotes(value);
185
+ else if (key === "ci_runner")
186
+ ciRunner = stripQuotes(value);
183
187
  else if (key === "ci_module_sha256")
184
188
  ciModuleSha256 = stripQuotes(value);
185
189
  }
@@ -306,6 +310,7 @@ function parseVersioned(raw) {
306
310
  lockVersion: LOCK_VERSION,
307
311
  bundles,
308
312
  ...(ciBinding !== undefined ? { ciBinding } : {}),
313
+ ...(ciRunner !== undefined ? { ciRunner } : {}),
309
314
  ...(ciWorkflowBlockSha256 !== undefined ? { ciWorkflowBlockSha256 } : {}),
310
315
  ...(ciModuleSha256 !== undefined ? { ciModuleSha256 } : {}),
311
316
  };
@@ -15,7 +15,7 @@ import { syncMethodManagedCopilotHooks } from "./copilot-hooks-merge.js";
15
15
  import { syncBriefingFiles } from "./briefing-merge.js";
16
16
  import { DEFAULT_AGENT_HARNESS } from "../agent-harness.js";
17
17
  import { BLOCK_BEGIN, BLOCK_END } from "../init/ci-workflow.js";
18
- import { blockSha256, CI_BINDINGS, DEFAULT_CI_BINDING, isCiBinding, syncCiBinding, } from "../ci-binding/index.js";
18
+ import { blockSha256, CI_BINDINGS, DEFAULT_CI_BINDING, isCiBinding, parseCiRunner, syncCiBinding, } from "../ci-binding/index.js";
19
19
  import { BRIEFING_TEMPLATE_ROOT, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, harnessWrapperOutputPath, } from "../briefing/index.js";
20
20
  const SECTION_RULE = "═".repeat(59);
21
21
  /**
@@ -97,6 +97,12 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
97
97
  return { exitCode: 1 };
98
98
  }
99
99
  const ciBinding = opts.ciBinding ?? lockBinding ?? DEFAULT_CI_BINDING;
100
+ const lockRunner = prevLock?.ciRunner === undefined ? undefined : parseCiRunner(prevLock.ciRunner);
101
+ if (lockRunner === null) {
102
+ stdout(fail(`method-baseline.lock names ci_runner '${prevLock.ciRunner}', which is not a list of runner labels. Re-run with --ci-runner <label[,label]> or --ci-runner default.`));
103
+ return { exitCode: 1 };
104
+ }
105
+ const ciRunner = opts.ciRunner ?? lockRunner;
100
106
  const classification = await classifyConflicts(conflicts, opts.targetRepo, prevEntry?.pathDigests);
101
107
  if (conflicts.length > 0) {
102
108
  reportClassification(stdout, conflicts.length, classification, prevEntry?.bundleVersion);
@@ -360,6 +366,7 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
360
366
  targetRepo: opts.targetRepo,
361
367
  profile: preflightProfile,
362
368
  binding: ciBinding,
369
+ ...(ciRunner !== undefined ? { runner: ciRunner } : {}),
363
370
  prevLock,
364
371
  allowDrift: false,
365
372
  allowOverwrite: false,
@@ -564,6 +571,7 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
564
571
  targetRepo: opts.targetRepo,
565
572
  profile,
566
573
  binding: ciBinding,
574
+ ...(ciRunner !== undefined ? { runner: ciRunner } : {}),
567
575
  prevLock,
568
576
  allowDrift: opts.allowOverwrite || migrateFrom !== null,
569
577
  allowOverwrite: opts.allowOverwrite,
@@ -596,10 +604,11 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
596
604
  // Record the wrapper block and the module exactly as they now stand on disk, so the next sync
597
605
  // can tell them untouched from edited (M-ADR 0218 §4.7), and the binding when it is not the default.
598
606
  const nextLock = upsertBundle(migrateFrom ? removeBundle(prevLock, migrateFrom.name) : prevLock, newEntry);
599
- const { ciBinding: _previousBinding, ciWorkflowBlockSha256: _previousSha, ...lockBase } = nextLock;
607
+ const { ciBinding: _previousBinding, ciRunner: _previousRunner, ciWorkflowBlockSha256: _previousSha, ...lockBase } = nextLock;
600
608
  await writeLockFile(opts.targetRepo, {
601
609
  ...lockBase,
602
610
  ...(ciBinding !== DEFAULT_CI_BINDING ? { ciBinding } : {}),
611
+ ...(ciRunner !== undefined && ciRunner.length > 0 ? { ciRunner: ciRunner.join(",") } : {}),
603
612
  ...(ci.wrapperSha !== null ? { ciWorkflowBlockSha256: ci.wrapperSha } : {}),
604
613
  ciModuleSha256: ci.moduleSha,
605
614
  });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.1.0-rc.0",
2
+ "version": "0.1.0",
3
3
  "built_against_sdd_cli": "1.42.0"
4
4
  }
@@ -2,7 +2,7 @@ import { AGENT_HARNESSES, resolveAgentHarness, } from "../lib/agent-harness.js";
2
2
  import { loadSyncConfig } from "../lib/sync/config.js";
3
3
  import { materialiseBundle } from "../lib/sync/bundle-source.js";
4
4
  import { runSync } from "../lib/sync/index.js";
5
- import { CI_BINDINGS, isCiBinding } from "../lib/ci-binding/index.js";
5
+ import { CI_BINDINGS, CI_RUNNER_DEFAULT, isCiBinding, parseCiRunner } from "../lib/ci-binding/index.js";
6
6
  import { resolvePathOption } from "./path-option.js";
7
7
  export function registerSync(program) {
8
8
  program
@@ -18,12 +18,22 @@ export function registerSync(program) {
18
18
  .option("--no-delete", "preserve files removed upstream")
19
19
  .option("--agent-harness <name>", `target agent harness for hooks + briefing. Default: claude-code. Supported: ${AGENT_HARNESSES.join(", ")}`)
20
20
  .option("--ci-binding <host>", `CI host whose wrapper calls the Method Validation module (M-ADR 0219): ${CI_BINDINGS.join(", ")}. Default: the binding the lock records, else github; a new choice is recorded in the lock`)
21
+ .option("--ci-runner <labels>", `runner label(s) the CI wrapper targets, comma-separated (GitHub runs-on, GitLab tags, Jenkins agent label), e.g. a self-hosted runner. '${CI_RUNNER_DEFAULT}' returns to the host's default runner. Default: what the lock records; a new choice is recorded`)
21
22
  .action(async (opts) => {
22
23
  if (opts.ciBinding !== undefined && !isCiBinding(opts.ciBinding)) {
23
24
  process.stderr.write(`[FAIL] --ci-binding must be one of ${CI_BINDINGS.join(", ")}; got '${opts.ciBinding}'\n`);
24
25
  process.exit(1);
25
26
  }
26
27
  const ciBinding = opts.ciBinding;
28
+ let ciRunner;
29
+ if (opts.ciRunner !== undefined) {
30
+ const parsed = opts.ciRunner === CI_RUNNER_DEFAULT ? [] : parseCiRunner(opts.ciRunner);
31
+ if (parsed === null) {
32
+ process.stderr.write(`[FAIL] --ci-runner takes comma-separated labels of letters, digits, '.', '_' or '-', or '${CI_RUNNER_DEFAULT}'; got '${opts.ciRunner}'\n`);
33
+ process.exit(1);
34
+ }
35
+ ciRunner = parsed;
36
+ }
27
37
  let agentHarness;
28
38
  try {
29
39
  agentHarness = resolveAgentHarness(opts.agentHarness);
@@ -50,6 +60,7 @@ export function registerSync(program) {
50
60
  delete: opts.delete,
51
61
  agentHarness,
52
62
  ...(ciBinding ? { ciBinding } : {}),
63
+ ...(ciRunner ? { ciRunner } : {}),
53
64
  ...(opts.replaceBundle ? { replaceBundle: opts.replaceBundle } : {}),
54
65
  ...(opts.yes === true ? { yes: true } : {}),
55
66
  });
@@ -78,6 +89,7 @@ export function registerSync(program) {
78
89
  delete: opts.delete,
79
90
  agentHarness,
80
91
  ...(ciBinding ? { ciBinding } : {}),
92
+ ...(ciRunner ? { ciRunner } : {}),
81
93
  });
82
94
  if (exitCode !== 0) {
83
95
  await resolved.cleanup();
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.1.0-rc.0",
2
+ "version": "1.1.0",
3
3
  "built_against_sdd_cli": "1.42.0"
4
4
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@sdd-method/maintain-cli",
3
- "version": "0.1.0-rc.0",
3
+ "version": "0.1.0",
4
4
  "sddCliVersion": "1.42.0",
5
- "description": "Bring an SDD repository current and rebuild its derived model \u2014 sync, briefing, catalogue build and aggregate, pack build, context pull \u2014 plus every read-only verify verb. Ships only the code those verbs reach; it cannot author specification content. Authoring is in @sdd-method/sdd-cli.",
5
+ "description": "Bring an SDD repository current and rebuild its derived model — sync, briefing, catalogue build and aggregate, pack build, context pull — plus every read-only verify verb. Ships only the code those verbs reach; it cannot author specification content. Authoring is in @sdd-method/sdd-cli.",
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",
8
8
  "bin": {