@intentius/chant-lexicon-github 0.124.54 → 0.124.56

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 (39) hide show
  1. package/dist/components/agent-jobs.d.ts +67 -0
  2. package/dist/components/agent-jobs.d.ts.map +1 -0
  3. package/dist/components/agent-jobs.js +139 -0
  4. package/dist/components/control-pipeline.d.ts +34 -0
  5. package/dist/components/control-pipeline.d.ts.map +1 -0
  6. package/dist/components/control-pipeline.js +45 -0
  7. package/dist/components/generate-op-pipeline.d.ts +8 -1
  8. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  9. package/dist/components/generate-op-pipeline.js +144 -16
  10. package/dist/components/generate-op-waves-pipeline.d.ts +27 -1
  11. package/dist/components/generate-op-waves-pipeline.d.ts.map +1 -1
  12. package/dist/components/generate-op-waves-pipeline.js +135 -16
  13. package/dist/components/generate-pipeline.d.ts.map +1 -1
  14. package/dist/components/generate-pipeline.js +160 -15
  15. package/dist/integrity.json +2 -2
  16. package/dist/manifest.json +1 -1
  17. package/package.json +2 -2
  18. package/src/components/__golden__/op-waves-jobs.yml +20 -0
  19. package/src/components/agent-jobs.ts +177 -0
  20. package/src/components/control-pipeline.ts +65 -0
  21. package/src/components/generate-op-pipeline.agent.test.ts +147 -0
  22. package/src/components/generate-op-pipeline.cloud.test.ts +96 -0
  23. package/src/components/generate-op-pipeline.notice.test.ts +2 -1
  24. package/src/components/generate-op-pipeline.ts +165 -20
  25. package/src/components/generate-op-waves-pipeline.approval.test.ts +46 -0
  26. package/src/components/generate-op-waves-pipeline.control.test.ts +64 -0
  27. package/src/components/generate-op-waves-pipeline.guard.test.ts +57 -0
  28. package/src/components/generate-op-waves-pipeline.identity.test.ts +8 -8
  29. package/src/components/generate-op-waves-pipeline.test.ts +17 -0
  30. package/src/components/generate-op-waves-pipeline.ts +157 -15
  31. package/src/components/generate-pipeline.ts +170 -15
  32. package/src/components/generate-pr-pipeline.comment-locks.test.ts +53 -0
  33. package/src/components/generate-pr-pipeline.comment-preview.test.ts +64 -0
  34. package/src/components/generate-pr-pipeline.comment-targets.test.ts +24 -0
  35. package/src/components/generate-pr-pipeline.control.test.ts +94 -0
  36. package/src/components/generate-pr-pipeline.pulumi-identity.test.ts +43 -0
  37. package/src/components/generate-pr-pipeline.respond.test.ts +47 -0
  38. package/src/components/generate-pr-pipeline.runners.test.ts +61 -0
  39. package/src/components/generate-pr-pipeline.stale-notes.test.ts +42 -0
@@ -22,6 +22,9 @@
22
22
  * downloads it. The forgejo generator reuses {@link buildGithubOpWavesDoc}
23
23
  * and applies its dialect.
24
24
  *
25
+ * `options.bin` (#4607) is a product's command in place of `chant` in every
26
+ * job line: the wave jobs, the resume job and the plans job.
27
+ *
25
28
  * With `resume` on the spec (#3683), a second workflow, `<name>-resume.yml`,
26
29
  * runs `chant run resume --op <name>` on that schedule: it re-runs the failed
27
30
  * jobs of a run whose waiting wave has been approved since, with the job's
@@ -31,6 +34,8 @@
31
34
  * `pull-requests: read`, so a wave can read the merged pull request's
32
35
  * reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
33
36
  * --record-plans` on each pull request to record the digests its head plans.
37
+ * A `pull-request` wave (#4630) gets the plans workflow too, for the approval
38
+ * status before merge; it asks for no `pull-requests: read` on the waves.
34
39
  *
35
40
  * With `notify` on the spec (#3761), the workflow maps each variable it names
36
41
  * from the repository secret of the same name, so a wave job can post to
@@ -49,9 +54,42 @@
49
54
  * checkout. Every later run step starts with an unset of the variables a
50
55
  * token is requested with, so the code the job runs holds no OIDC token
51
56
  * and cannot request one for another group's identity.
57
+ *
58
+ * With `control` on the spec (#4617), a `control-dispatch` job needs the
59
+ * last wave's jobs and runs after them unless the run was cancelled, so it
60
+ * runs after a failure too. It downloads every job's outcome file, adds
61
+ * them up with `chant run wave --waves-outcome` (through the product's
62
+ * bin) into `CHANT_CONTROL_OUTCOME`, and sends the control repo one
63
+ * dispatch carrying that outcome and the pushed commit. Only the dispatch
64
+ * step holds the control token: the `tokenSecret` secret, mapped into
65
+ * `CHANT_CONTROL_TOKEN` and handed to curl on its stdin
66
+ * (`controlDispatchScript`, #4568).
67
+ *
68
+ * Every wave, deciding and share job starts with the apply-job guard
69
+ * (#4624, `@intentius/chant/ci-job-guard`), before the trade and the
70
+ * checkout: it stops the job unless `GITHUB_REF` is one of the spec's
71
+ * branches, the job runs in its forge environment (read from an OIDC
72
+ * token's claims, so only in a job that may request one), and, for a
73
+ * runner whose labels include `self-hosted`, on a self-hosted runner. The
74
+ * forgejo generator passes `forge: "forgejo"`, which checks the ref and
75
+ * runner only.
52
76
  */
53
77
 
54
- import { notifyVariables, opWaveGroupOf, opWaveJobs, opWaveOutcomeDir, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
78
+ import {
79
+ notifyVariables,
80
+ opWaveGroupOf,
81
+ opWaveJobs,
82
+ opWaveOutcomeDir,
83
+ opWavesControlTarget,
84
+ opWavesLastJobs,
85
+ opWavesApprovalStatusCommand,
86
+ opWavesPostsApprovalStatus,
87
+ opWavesOutcomeCommand,
88
+ opWavesRecordPlansCommand,
89
+ opWavesResumeCommand,
90
+ type OpWavesSpec,
91
+ } from "@intentius/chant/op/op-waves";
92
+ import { CONTROL_OUTCOME_VAR, controlDispatchJob } from "@intentius/chant/control/dispatch";
55
93
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
56
94
  import {
57
95
  assertEnvironment,
@@ -68,6 +106,8 @@ import {
68
106
  setupStepDoc,
69
107
  } from "@intentius/chant/ci-jobs";
70
108
  import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
109
+ import { JOB_GUARD_ENV, jobGuardLine, jobGuardSpec, opWaveJobLabel } from "@intentius/chant/ci-job-guard";
110
+ import { resolveProductWords } from "@intentius/chant/ci-product-words";
71
111
  import { checkoutStep, concurrency, DOWNLOAD_ARTIFACT, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs";
72
112
  import { emitOpPipelineYAML, type GithubOpPipelineDoc } from "./generate-op-pipeline";
73
113
 
@@ -88,10 +128,15 @@ export function opWaveOutcomeArtifact(spec: Pick<OpWavesSpec, "name">, jobName:
88
128
  export function buildGithubOpWavesDoc(
89
129
  spec: OpWavesSpec,
90
130
  options: OpWavesPipelineOptions,
131
+ dialect: { forge?: "github" | "forgejo" } = {},
91
132
  ): { doc: GithubOpPipelineDoc; jobs: OpWavesPipelineResult["jobs"]; resumeDoc?: GithubOpPipelineDoc; plansDoc?: GithubOpPipelineDoc } {
92
- const jobs = opWaveJobs(spec, options.specFile);
133
+ const jobs = opWaveJobs(spec, options.specFile, { bin: options.bin });
134
+ const forge = dialect.forge ?? "github";
135
+ const product = resolveProductWords(options.words ?? {}).product;
93
136
  const image = options.image ?? OP_WAVES_IMAGE;
94
137
  const byReview = spec.waves.some((w) => w.approval === "pr-review");
138
+ // #4630: a pr-review or pull-request wave gets the plans workflow and its approval status.
139
+ const statusBeforeMerge = opWavesPostsApprovalStatus(spec);
95
140
  for (const wave of spec.waves) {
96
141
  assertSetupSteps(`${spec.name}/${wave.name}`, wave.setup ?? []);
97
142
  if (wave.environment) assertEnvironment(`${spec.name}/${wave.name}`, wave.environment);
@@ -109,7 +154,21 @@ export function buildGithubOpWavesDoc(
109
154
  const group = opWaveGroupOf(spec, job);
110
155
  // #4332: the job's cloud identities, traded before the checkout.
111
156
  const cloud = group?.cloud ?? wave.cloud;
112
- const steps: Array<Record<string, unknown>> = [...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
157
+ const environment = group?.environment ?? wave.environment;
158
+ const idTokens = group?.idTokens ?? wave.idTokens;
159
+ const idToken = idTokens !== undefined || cloud !== undefined;
160
+ const runner = group?.runner ?? wave.runner ?? spec.runner;
161
+ // #4624: the guard first, before the trade and the checkout.
162
+ const guard = guardStep({
163
+ forge,
164
+ job: opWaveJobLabel(spec.name, job),
165
+ product,
166
+ ...(environment && forge === "github" ? { environment: environment.name } : {}),
167
+ branches: pushBranches(spec.branches),
168
+ ...(runner !== undefined ? { runner } : {}),
169
+ idToken,
170
+ });
171
+ const steps: Array<Record<string, unknown>> = [guard, ...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
113
172
  if (job.kind === "share") {
114
173
  steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record!)));
115
174
  }
@@ -126,11 +185,8 @@ export function buildGithubOpWavesDoc(
126
185
  steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record!));
127
186
  }
128
187
  steps.push(keepRecordStep(`Keep the job's outcome`, opWaveOutcomeArtifact(spec, job.jobName), job.outcome));
129
- const environment = group?.environment ?? wave.environment;
130
- const idTokens = group?.idTokens ?? wave.idTokens;
131
- const idToken = idTokens !== undefined || cloud !== undefined;
132
188
  jobsDoc[job.jobName] = {
133
- "runs-on": githubRunsOn(group?.runner ?? wave.runner ?? spec.runner),
189
+ "runs-on": githubRunsOn(runner),
134
190
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
135
191
  // #4322: a wave's jobs run after a failure in the wave before; a share job runs only on its deciding job's success.
136
192
  ...(job.needs.length > 0 && job.kind !== "share" ? { if: "!cancelled()" } : {}),
@@ -143,6 +199,12 @@ export function buildGithubOpWavesDoc(
143
199
  };
144
200
  }
145
201
 
202
+ // #4617: once the last wave ran, one job tells the control repo how the waves ended.
203
+ if (spec.control) {
204
+ const [name, job] = controlJob(spec, options, jobs, image, permissions);
205
+ jobsDoc[name] = job;
206
+ }
207
+
146
208
  // #3761: the notify variables come from the secrets of the same name.
147
209
  const env = { ...githubSecretEnv(notifyVariables(spec.notify)), ...(options.variables ?? {}) };
148
210
  const doc: GithubOpPipelineDoc = {
@@ -157,16 +219,62 @@ export function buildGithubOpWavesDoc(
157
219
  doc,
158
220
  jobs,
159
221
  ...(spec.resume ? { resumeDoc: buildResumeDoc(spec, options) } : {}),
160
- ...(byReview ? { plansDoc: buildPlansDoc(spec, options) } : {}),
222
+ ...(statusBeforeMerge ? { plansDoc: buildPlansDoc(spec, options, forge) } : {}),
161
223
  };
162
224
  }
163
225
 
226
+ /**
227
+ * The control dispatch job (#4617): after the last wave, whatever it did,
228
+ * the waves' outcome read from every job's outcome file, then one dispatch
229
+ * carrying it. The pushed commit is the one every wave ran at.
230
+ */
231
+ function controlJob(
232
+ spec: OpWavesSpec,
233
+ options: OpWavesPipelineOptions,
234
+ jobs: OpWavesPipelineResult["jobs"],
235
+ image: string,
236
+ permissions: Record<string, unknown>,
237
+ ): [string, Record<string, unknown>] {
238
+ const control = spec.control!;
239
+ const dispatch = controlDispatchJob("github", opWavesControlTarget(control), "${{ github.sha }}", {
240
+ ...(options.words ? { words: options.words } : {}),
241
+ ...(control.sentCheck ? { sentCheck: control.sentCheck } : {}),
242
+ ...(control.markSent ? { markSent: control.markSent } : {}),
243
+ outcome: true,
244
+ });
245
+ const marks = control.sentCheck !== undefined || control.markSent !== undefined;
246
+ const steps: Array<Record<string, unknown>> = [
247
+ checkoutStep(true),
248
+ { name: "Download the waves' outcomes", uses: DOWNLOAD_ARTIFACT, with: { path: opWaveOutcomeDir(spec) } },
249
+ ...(spec.waves[0]?.setup ?? []).map(setupStepDoc),
250
+ ...runSteps(options.beforeScript ?? []),
251
+ {
252
+ name: "Add up the waves' outcomes",
253
+ run: `outcome="$(${opWavesOutcomeCommand(options.specFile, options.bin).join(" ")})" || exit 1\necho "${CONTROL_OUTCOME_VAR}=$outcome" >> "$GITHUB_ENV"`,
254
+ },
255
+ { name: `Tell the control repo ${control.repo} how ${control.member}'s waves ended`, env: { ...dispatch.env, ...(marks ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN } : {}) }, run: dispatch.script },
256
+ ];
257
+ return [
258
+ dispatch.name,
259
+ {
260
+ "runs-on": githubRunsOn(spec.runner),
261
+ needs: opWavesLastJobs(jobs),
262
+ // The waves ran, whatever they did: a failed run is an outcome the control repo is told about.
263
+ if: "!cancelled()",
264
+ container: image,
265
+ // markSent sets a commit status; the rest reads the repository and the run's artifacts.
266
+ permissions: marks ? { ...permissions, statuses: "write" } : permissions,
267
+ steps,
268
+ },
269
+ ];
270
+ }
271
+
164
272
  /** The scheduled resume workflow (#3683). */
165
273
  function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
166
274
  const steps: Array<Record<string, unknown>> = [checkoutStep(), ...runSteps(options.beforeScript ?? [])];
167
275
  steps.push({
168
276
  name: `Resume the waves of ${spec.name} whose approval arrived`,
169
- run: opWavesResumeCommand(spec).join(" "),
277
+ run: opWavesResumeCommand(spec, options.bin).join(" "),
170
278
  env: { GITHUB_TOKEN: GITHUB_JOB_TOKEN },
171
279
  });
172
280
  return {
@@ -179,21 +287,55 @@ function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): Git
179
287
  };
180
288
  }
181
289
 
182
- /** The pull request workflow a `pr-review` wave needs (#3684): record the digests each head plans. */
183
- function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
290
+ /**
291
+ * The pull request workflow a `pr-review` wave needs (#3684): record the
292
+ * digests each head plans. The `approval-status` job after it (#4630) posts
293
+ * `<product>/approval` on the head, and runs alone on each review submitted
294
+ * or dismissed. It holds a read token that may write statuses and nothing
295
+ * else. A `pull-request` wave gets the same workflow: the record keeps its
296
+ * change set, and the status reads the pull request's own approval of it.
297
+ */
298
+ function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions, forge: "github" | "forgejo"): GithubOpPipelineDoc {
184
299
  const steps: Array<Record<string, unknown>> = [checkoutStep(true), ...(spec.waves[0]?.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? [])];
185
- steps.push({ name: "Plan every wave at the head and record its digests", run: ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ") });
300
+ steps.push({ name: "Plan every wave at the head and record its digests", run: opWavesRecordPlansCommand(options.specFile, options.bin).join(" ") });
301
+ const statusSteps: Array<Record<string, unknown>> = [checkoutStep(true), ...runSteps(options.beforeScript ?? [])];
302
+ statusSteps.push({
303
+ name: "Post the approval status on the head",
304
+ run: opWavesApprovalStatusCommand(options.specFile, forge, options.bin).join(" "),
305
+ env: {
306
+ GITHUB_TOKEN: GITHUB_JOB_TOKEN,
307
+ PR_NUMBER: "${{ github.event.pull_request.number }}",
308
+ HEAD_SHA: "${{ github.event.pull_request.head.sha }}",
309
+ BASE_SHA: "${{ github.event.pull_request.base.sha }}",
310
+ },
311
+ });
186
312
  return {
187
313
  name: `${spec.name}-plans`,
188
- on: { pull_request: { branches: pushBranches(spec.branches) } },
314
+ on: { pull_request: { branches: pushBranches(spec.branches) }, pull_request_review: { types: ["submitted", "dismissed"] } },
189
315
  ...nonEmpty("env", options.variables),
190
- concurrency: concurrency(spec.name + "-plans-${{ github.event.number }}", true),
316
+ concurrency: concurrency(spec.name + "-plans-${{ github.event_name }}-${{ github.event.pull_request.number }}", true),
191
317
  // contents: write pushes the record to chant/lifecycle.
192
318
  permissions: { contents: "write" },
193
- jobsDoc: { "record-plans": { "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps } },
319
+ jobsDoc: {
320
+ "record-plans": { if: "github.event_name == 'pull_request'", "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps },
321
+ "approval-status": {
322
+ needs: ["record-plans"],
323
+ // On a review the record-plans job is skipped, and the status reads the plans an earlier push recorded.
324
+ if: "${{ always() && (needs.record-plans.result == 'success' || needs.record-plans.result == 'skipped') }}",
325
+ "runs-on": githubRunsOn(spec.runner),
326
+ container: options.image ?? OP_WAVES_IMAGE,
327
+ permissions: { contents: "read", "pull-requests": "read", statuses: "write" },
328
+ steps: statusSteps,
329
+ },
330
+ },
194
331
  };
195
332
  }
196
333
 
334
+ /** The apply-job guard step (#4624): the job's expectation in CHANT_JOB_GUARD, checked by `node -e` from `/`. */
335
+ function guardStep(input: Parameters<typeof jobGuardSpec>[0]): Record<string, unknown> {
336
+ return { name: "Check the job runs where the spec set it to run", env: { [JOB_GUARD_ENV]: JSON.stringify(jobGuardSpec(input)) }, run: jobGuardLine() };
337
+ }
338
+
197
339
  /** The step that trades the job's OIDC token for `cloud` (#4332), writing the credentials to GITHUB_ENV. */
198
340
  function tradeStep(cloud: CloudTradeSpec): Record<string, unknown> {
199
341
  return { name: "Trade the job's OIDC token for its cloud identities", env: { [TRADE_SPEC_ENV]: JSON.stringify(cloud) }, run: tradeLine("$GITHUB_ENV", "github-env") };
@@ -59,11 +59,11 @@
59
59
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
60
60
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
61
61
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
62
- import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
62
+ import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_JOB_TOKEN, GITHUB_RUNNER, githubRunsOn } from "@intentius/chant/ci-jobs";
63
63
  import { CHECKOUT_TOKEN_ENV, checkoutScript, dropForgeTokens, handoverOutputs, handoverTrap, takeOverScript } from "@intentius/chant/ci-handover";
64
64
  import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
65
65
  import {
66
- APPLY_COMMENT,
66
+ prApplyComment,
67
67
  GREEN_TAG_PATTERN,
68
68
  GREEN_TICK_WORKFLOW,
69
69
  PR_APPLY_RECORD,
@@ -80,14 +80,25 @@ import {
80
80
  PR_UNLOCK_JOB,
81
81
  PR_UNLOCK_COMMAND,
82
82
  prLockCommand,
83
+ prCommentLockJobs,
84
+ prCommentPreviewJob,
85
+ PREVIEW_REF_ENV,
86
+ PREVIEW_HEAD_OUTPUT,
83
87
  prLoopWaveJobs,
84
88
  prLoopWithWaves,
85
89
  prEnvJobs,
90
+ prLoopJobStage,
91
+ prStaleJob,
92
+ PR_STALE_JOB,
93
+ prLoopRunner,
86
94
  PR_ENV_REPORT_DIR,
87
95
  PR_ENV_SWEEP_CRON,
88
96
  PR_ENV_POST_JOB,
89
97
  } from "@intentius/chant/components/pr-pipeline";
98
+ import { forgejoRepoLockLines, REPO_APPLY_GROUP } from "@intentius/chant/components/repo-lock";
90
99
  import { memberRepoPath } from "@intentius/chant/lexicon";
100
+ import { productStatusContext, resolveProductWords } from "@intentius/chant/ci-product-words";
101
+ import { controlDispatchJob, controlNoteCheckout } from "@intentius/chant/control/dispatch";
91
102
  import type {
92
103
  ComponentPipelineJob as GeneratedJob,
93
104
  ComponentPipelineOptions as GenerateGithubOptions,
@@ -137,6 +148,8 @@ export interface GithubPipelineDoc {
137
148
 
138
149
  /** The plan job's last step, whose outputs carry its report to the plan-note job (#4295). */
139
150
  const PLAN_STEP_ID = "plan";
151
+ /** The comment preview's checkout step (#4620), whose output names the head it checked out. */
152
+ const PREVIEW_HEAD_STEP_ID = "head";
140
153
 
141
154
  /** The output a gated apply wave hands the green commit it applies through, and the step that sets it (#4221). */
142
155
  const WAVE_GREEN_OUTPUT = "green-sha";
@@ -292,10 +305,12 @@ function prLoopGithubDoc(
292
305
  });
293
306
  const withIdToken = (permissions: Record<string, string>): Record<string, string> => (identity ? { ...permissions, "id-token": "write" } : permissions);
294
307
  const reportName = (job: typeof plan) => `chant-pr-${job.wave ? prApplyWaveJobName(job.wave) : job.jobName}`;
308
+ // #4552: on the repo grain a Forgejo apply takes the lock tag in its own step, before the command, since the runner does not queue on a concurrency group.
309
+ const repoLock = (job: typeof plan): string[] => (loop.lockGrain === "repo" && loop.forge === "forgejo" && job.jobName !== "record" ? forgejoRepoLockLines() : []);
295
310
  const steps = (job: typeof plan, name: string): Array<Record<string, unknown>> => [
296
311
  ...(identity ? [tradeStep(job.jobName === "record" ? identity.plan : identity.apply, job.jobName === "record" ? "plan" : "apply")] : []),
297
312
  checkoutStep(true),
298
- ...runSteps(prLoopSetup(image)),
313
+ ...runSteps(prLoopSetup(image, loop.engineSetup)),
299
314
  ...runSteps(beforeScript),
300
315
  ...(job.build ? [{ name: "Build the templates", run: job.build[0] }, { name: "Lint the project", run: job.build[1] }] : []),
301
316
  ...(job.jobName === "apply" ? [restoreRecord(job.wave)] : []),
@@ -307,9 +322,9 @@ function prLoopGithubDoc(
307
322
  name,
308
323
  id: WAVE_STEP_ID,
309
324
  env: { ...job.run.env, ...(job.wave > 1 ? { GREEN_SHA: `\${{ needs.${prApplyWaveJobName(job.wave - 1)}.outputs.${WAVE_GREEN_OUTPUT} }}` } : {}) },
310
- run: [...(job.checkout ?? []), `echo "${WAVE_GREEN_OUTPUT}=$GREEN_SHA" >> "$GITHUB_OUTPUT"`, ...(job.setup ?? []), job.run.command].join("\n"),
325
+ run: [...(job.checkout ?? []), `echo "${WAVE_GREEN_OUTPUT}=$GREEN_SHA" >> "$GITHUB_OUTPUT"`, ...(job.setup ?? []), ...repoLock(job), job.run.command].join("\n"),
311
326
  }
312
- : { name, env: job.run.env, run: [...(job.checkout ?? []), ...(job.setup ?? []), job.run.command].join("\n") },
327
+ : { name, env: job.run.env, run: [...(job.checkout ?? []), ...(job.setup ?? []), ...repoLock(job), job.run.command].join("\n") },
313
328
  ...runSteps(extraScript),
314
329
  ...(job.jobName === "apply" ? [saveRecord(job.wave)] : []),
315
330
  {
@@ -328,7 +343,7 @@ function prLoopGithubDoc(
328
343
  "github.event_name == 'issue_comment'",
329
344
  // Forgejo's payload says so with is_pull (terragucci comment-apply.ts:129 at e6cb431).
330
345
  forgejo ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
331
- `startsWith(github.event.comment.body, '${APPLY_COMMENT}')`,
346
+ `startsWith(github.event.comment.body, '${prApplyComment(loop)}')`,
332
347
  ...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
333
348
  ].join(" && ");
334
349
  const applyJob = (job: typeof plan, needs: string[], name: string) => ({
@@ -337,7 +352,8 @@ function prLoopGithubDoc(
337
352
  "runs-on": GITHUB_RUNNER,
338
353
  ...(needs.length > 0 ? { needs } : {}),
339
354
  container: image,
340
- concurrency: concurrency(prApplyGroup(env, member?.name)),
355
+ // #4552: on the repo grain every apply of the repository shares one group.
356
+ concurrency: concurrency(loop.lockGrain === "repo" ? REPO_APPLY_GROUP : prApplyGroup(env, member?.name)),
341
357
  permissions: withIdToken({ contents: "write", "pull-requests": "write", statuses: "write" }),
342
358
  ...(job.wave ? { outputs: { [WAVE_GREEN_OUTPUT]: `\${{ steps.${WAVE_STEP_ID}.outputs.${WAVE_GREEN_OUTPUT} }}` } } : {}),
343
359
  steps: steps(job, name),
@@ -370,7 +386,7 @@ function prLoopGithubDoc(
370
386
  env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
371
387
  run: checkoutScript(true, true),
372
388
  },
373
- ...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
389
+ ...[...prLoopSetup(image, loop.engineSetup), ...beforeScript].map((line) => headStep(line)),
374
390
  ...(plan.build ? [headStep(plan.build[0]!, "Build the templates"), headStep(plan.build[1]!, "Lint the project")] : []),
375
391
  // The job's last step: a step after it would start beside whatever the change's code left running.
376
392
  {
@@ -380,14 +396,58 @@ function prLoopGithubDoc(
380
396
  run: [drop, ...handoverTrap(PR_LOOP_REPORT_DIR, forgeName), ...(plan.checkout ?? []), ...(plan.setup ?? []), plan.run.command, ...extraScript].join("\n"),
381
397
  },
382
398
  ];
383
- const post = prLoopPostJob(forgeName, env, member?.name);
399
+ // #4568: once the last apply on merge succeeded, one job tells the control repo this member applied.
400
+ const lastGreen = loop.control && !loop.applyBeforeMerge && greenJobs.length > 0 ? greenJobs[greenJobs.length - 1]![0] : undefined;
401
+ const controlJobs: Array<[string, Record<string, unknown>]> = [];
402
+ if (loop.control && lastGreen) {
403
+ // A wave job hands on the commit it applied; a single apply job applies the green commit its trigger names.
404
+ const sha = loop.waveGate ? `\${{ needs.${lastGreen}.outputs.${WAVE_GREEN_OUTPUT} }}` : forgejo ? "${{ github.sha }}" : "${{ github.event.workflow_run.head_sha || github.sha }}";
405
+ const dispatch = controlDispatchJob(forgeName, loop.control.target, sha, {
406
+ ...(loop.words ? { words: loop.words } : {}),
407
+ ...(loop.control.sentCheck ? { sentCheck: loop.control.sentCheck } : {}),
408
+ ...(loop.control.markSent ? { markSent: loop.control.markSent } : {}),
409
+ });
410
+ controlJobs.push([
411
+ dispatch.name,
412
+ {
413
+ needs: [lastGreen],
414
+ if: `success() && (${onGreen})`,
415
+ "runs-on": GITHUB_RUNNER,
416
+ container: image,
417
+ permissions: loop.control.markSent ? { contents: "read", statuses: "write" } : { contents: "read" },
418
+ steps: [{ name: dispatch.stepName, ...(rooted ? {} : { "working-directory": "." }), env: { ...dispatch.env, ...(loop.control.sentCheck || loop.control.markSent ? { GITHUB_TOKEN: GITHUB_JOB_TOKEN } : {}) }, run: dispatch.script }],
419
+ },
420
+ ]);
421
+ }
422
+ const post = prLoopPostJob(forgeName, env, member?.name, loop.words ? resolveProductWords(loop.words) : undefined, { ...(loop.staleNotes ? { staleNotes: true } : {}), ...(loop.respond ? { respond: loop.respond } : {}), ...(loop.decide ? { decide: loop.decide } : {}) });
423
+ // #4628: on a push to the target branch, the open pull requests' notes posted against another commit are marked stale.
424
+ const stale = prStaleJob(forgeName, env, loop, member?.name);
425
+ const staleJobs: Record<string, unknown> = stale
426
+ ? {
427
+ [PR_STALE_JOB]: {
428
+ if: "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')",
429
+ "runs-on": GITHUB_RUNNER,
430
+ container: image,
431
+ permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
432
+ steps: [
433
+ // The pushed commit: the target branch's, so none of a pull request's code is here.
434
+ { name: "Check out the target branch's commit", uses: CHECKOUT },
435
+ ...runSteps(beforeScript),
436
+ { name: "Mark the open pull requests' notes stale", ...(rooted ? {} : { "working-directory": "." }), env: stale.env, run: stale.command },
437
+ ],
438
+ },
439
+ }
440
+ : {};
441
+ // #4619: with a control repo named, the note job reads its chant/lifecycle, read-only, so pr-post marks the cross-stack edges.
442
+ const controlRead = loop.control ? controlNoteCheckout(forgeName, loop.control.target, loop.words ?? {}) : undefined;
384
443
  const noteSteps: Array<Record<string, unknown>> = [
385
444
  // The target branch's commit: the chant this job runs is the target branch's, and none of the change's code is here.
386
445
  { name: "Check out the target branch's commit", uses: CHECKOUT, with: { ref: "${{ github.event.pull_request.base.sha }}" } },
387
446
  ...runSteps(beforeScript),
388
447
  { name: "Take the plan report from the plan job", run: takeOverScript("plan", PR_LOOP_REPORT_DIR, forgeName) },
389
448
  { name: "Keep the plan report", if: "always()", uses: UPLOAD_ARTIFACT, with: { name: reportName(plan), path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } },
390
- { name: "Post the plan note and chant/plan", if: "always()", env: post.env, run: post.command },
449
+ ...(controlRead ? [{ name: controlRead.stepName, if: "always()", env: controlRead.env, run: controlRead.script }] : []),
450
+ { name: `Post the plan note and ${productStatusContext(resolveProductWords(loop.words), "plan")}`, if: "always()", env: post.env, run: post.command },
391
451
  ];
392
452
  // #4298: with a pull request environment the pull_request trigger fires on close too, and the plan skips that event.
393
453
  const prEnv = loop.prEnvironment ? prEnvGithubJobs(loop.forge ?? "github", image, beforeScript, extraScript, loop, member) : undefined;
@@ -428,6 +488,82 @@ function prLoopGithubDoc(
428
488
  },
429
489
  }
430
490
  : {};
491
+ // #4588: `/<product> lock` and `/<product> unlock` comments, at the target branch's commit, so none of the change's code runs.
492
+ const commentLocks = prCommentLockJobs(forgeName, loop);
493
+ const commentLockJobs: Record<string, unknown> = Object.fromEntries(
494
+ commentLocks.map((job) => [
495
+ job.jobName,
496
+ {
497
+ if: [
498
+ "github.event_name == 'issue_comment'",
499
+ forgejo ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
500
+ `startsWith(github.event.comment.body, '${job.comment}')`,
501
+ ...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
502
+ ].join(" && "),
503
+ "runs-on": GITHUB_RUNNER,
504
+ container: image,
505
+ permissions: { contents: "write", "pull-requests": "read", statuses: "write" },
506
+ steps: [
507
+ { name: "Check out the target branch's commit", uses: CHECKOUT, with: { "fetch-depth": 0 } },
508
+ ...runSteps(beforeScript),
509
+ { name: job.command === "lock" ? "Keep the locks the pull request's plan took" : "Release the pull request's locks", env: job.env, run: job.line },
510
+ ],
511
+ },
512
+ ]),
513
+ );
514
+ // #4620: `/<product> <plan word>` plans the pull request's head again with a read token; the job after it posts the note.
515
+ const preview = prCommentPreviewJob(forgeName, env, loop, member?.name);
516
+ const previewJobs: Record<string, unknown> = preview
517
+ ? {
518
+ [preview.jobName]: {
519
+ if: [
520
+ "github.event_name == 'issue_comment'",
521
+ forgejo ? "(github.event.issue.pull_request || github.event.is_pull)" : "github.event.issue.pull_request",
522
+ `startsWith(github.event.comment.body, '${preview.comment}')`,
523
+ ...(forgejo ? [] : ["contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)"]),
524
+ ].join(" && "),
525
+ "runs-on": GITHUB_RUNNER,
526
+ container: image,
527
+ permissions: withIdToken({ contents: "read" }),
528
+ outputs: { ...handoverOutputs(PLAN_STEP_ID, forgeName), [PREVIEW_HEAD_OUTPUT]: `\${{ steps.${PREVIEW_HEAD_STEP_ID}.outputs.${PREVIEW_HEAD_OUTPUT} }}` },
529
+ steps: [
530
+ ...(identity ? [tradeStep(identity.plan, "plan")] : []),
531
+ // The head is read here, before any of the change's code runs, so the post job names the commit this job planned.
532
+ {
533
+ name: "Check out the pull request's head",
534
+ id: PREVIEW_HEAD_STEP_ID,
535
+ ...(rooted ? {} : { "working-directory": "." }),
536
+ env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN, [PREVIEW_REF_ENV]: "ref" in preview.head ? preview.head.ref : "" },
537
+ run: [checkoutScript(true, true, { refEnv: PREVIEW_REF_ENV }), `echo "${PREVIEW_HEAD_OUTPUT}=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"`].join("\n"),
538
+ },
539
+ ...[...prLoopSetup(image, loop.engineSetup), ...beforeScript].map((line) => headStep(line)),
540
+ ...(preview.plan.build ? [headStep(preview.plan.build[0]!, "Build the templates"), headStep(preview.plan.build[1]!, "Lint the project")] : []),
541
+ {
542
+ name: "Preview the members this pull request reaches",
543
+ id: PLAN_STEP_ID,
544
+ env: preview.plan.run.env,
545
+ run: [drop, ...handoverTrap(PR_LOOP_REPORT_DIR, forgeName), preview.base, ...(preview.plan.setup ?? []), preview.plan.run.command, ...extraScript].join("\n"),
546
+ },
547
+ ],
548
+ },
549
+ [preview.noteJobName]: {
550
+ needs: [preview.jobName],
551
+ if: `always() && github.event_name == 'issue_comment' && (needs.${preview.jobName}.result == 'success' || needs.${preview.jobName}.result == 'failure')`,
552
+ "runs-on": GITHUB_RUNNER,
553
+ container: image,
554
+ permissions: { actions: "read", contents: "read", "pull-requests": "write", statuses: "write" },
555
+ steps: [
556
+ // The target branch's commit, which an issue_comment run checks out: none of the change's code is here.
557
+ { name: "Check out the target branch's commit", uses: CHECKOUT },
558
+ ...runSteps(beforeScript),
559
+ { name: "Take the preview report from the preview job", run: takeOverScript(preview.jobName, PR_LOOP_REPORT_DIR, forgeName) },
560
+ { name: "Keep the preview report", if: "always()", uses: UPLOAD_ARTIFACT, with: { name: `chant-pr-${preview.jobName}`, path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } },
561
+ ...(controlRead ? [{ name: controlRead.stepName, if: "always()", env: controlRead.env, run: controlRead.script }] : []),
562
+ { name: `Post the preview note and ${productStatusContext(resolveProductWords(loop.words), "plan")}`, if: "always()", env: preview.post.env, run: preview.post.command },
563
+ ],
564
+ },
565
+ }
566
+ : {};
431
567
  const jobsDoc: Record<string, unknown> = {
432
568
  plan: {
433
569
  if: closes ? "github.event_name == 'pull_request' && github.event.action != 'closed'" : "github.event_name == 'pull_request'",
@@ -447,7 +583,11 @@ function prLoopGithubDoc(
447
583
  steps: noteSteps,
448
584
  },
449
585
  ...lockJobs,
586
+ ...commentLockJobs,
587
+ ...previewJobs,
588
+ ...staleJobs,
450
589
  ...Object.fromEntries(applyJobs),
590
+ ...Object.fromEntries(controlJobs),
451
591
  ...(prEnv ? prEnv.jobs : {}),
452
592
  };
453
593
  const applyNames = applyJobs.map(([name]) => name);
@@ -461,8 +601,14 @@ function prLoopGithubDoc(
461
601
  on.pull_request = { branches: [branch], types: ["opened", "synchronize", "reopened", "closed"] };
462
602
  on.schedule = [{ cron: PR_ENV_SWEEP_CRON }];
463
603
  }
464
- // #4301: a /chant apply comment starts the apply before merge.
465
- if (loop.applyBeforeMerge) on.issue_comment = { types: ["created"] };
604
+ // #4301: a /chant apply comment starts the apply before merge. #4588: and a lock or unlock comment its job.
605
+ if (loop.applyBeforeMerge || commentLocks.length > 0 || preview) on.issue_comment = { types: ["created"] };
606
+ // #4625: each job on its stage's runner, the plan side's, the apply side's, or the default.
607
+ if (loop.runners) {
608
+ for (const [name, job] of Object.entries(jobsDoc) as Array<[string, Record<string, unknown>]>) {
609
+ job["runs-on"] = githubRunsOn(prLoopRunner(loop, prLoopJobStage(name)));
610
+ }
611
+ }
466
612
  return {
467
613
  name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
468
614
  environment: env,
@@ -470,16 +616,24 @@ function prLoopGithubDoc(
470
616
  ...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
471
617
  env: { ...options.variables, CHANT_ENV: env },
472
618
  jobsDoc,
473
- stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...applyNames, ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
619
+ stages: ["plan", PR_PLAN_NOTE_JOB, ...(locks ? [PR_LOCK_JOB, PR_UNLOCK_JOB] : []), ...commentLocks.map((j) => j.jobName), ...(preview ? [preview.jobName, preview.noteJobName] : []), ...(stale ? [PR_STALE_JOB] : []), ...applyNames, ...controlJobs.map(([name]) => name), ...(prEnv ? ["env-up", PR_ENV_POST_JOB, "env-down", "env-sweep"] : [])],
474
620
  jobs: [
475
621
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
476
622
  { jobName: PR_PLAN_NOTE_JOB, component: "pull request plan note", stage: PR_PLAN_NOTE_JOB, needs: ["plan"] },
623
+ ...(stale ? [{ jobName: PR_STALE_JOB, component: "pull request stale notes", stage: PR_STALE_JOB, needs: [] }] : []),
477
624
  ...(locks
478
625
  ? [
479
626
  { jobName: PR_LOCK_JOB, component: "pull request lock", stage: PR_LOCK_JOB, needs: ["plan"] },
480
627
  { jobName: PR_UNLOCK_JOB, component: "pull request unlock", stage: PR_UNLOCK_JOB, needs: [] },
481
628
  ]
482
629
  : []),
630
+ ...commentLocks.map((j) => ({ jobName: j.jobName, component: `pull request ${j.command} comment`, stage: j.jobName, needs: [] })),
631
+ ...(preview
632
+ ? [
633
+ { jobName: preview.jobName, component: "pull request preview comment", stage: preview.jobName, needs: [] },
634
+ { jobName: preview.noteJobName, component: "pull request preview comment note", stage: preview.noteJobName, needs: [preview.jobName] },
635
+ ]
636
+ : []),
483
637
  ...applyNames.map((name, i) => ({
484
638
  jobName: name,
485
639
  component: name === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
@@ -487,6 +641,7 @@ function prLoopGithubDoc(
487
641
  // The record runs on a push and the apply on a green commit, so only the waves need each other.
488
642
  needs: i > 1 ? [applyNames[i - 1]] : [],
489
643
  })),
644
+ ...controlJobs.map(([name]) => ({ jobName: name, component: "control repo dispatch", stage: name, needs: [lastGreen!] })),
490
645
  ...(prEnv ? prEnv.listed : []),
491
646
  ],
492
647
  };
@@ -547,7 +702,7 @@ function prEnvGithubJobs(
547
702
  env: { [CHECKOUT_TOKEN_ENV]: GITHUB_JOB_TOKEN },
548
703
  run: checkoutScript(true, true),
549
704
  },
550
- ...[...prLoopSetup(image), ...beforeScript].map((line) => headStep(line)),
705
+ ...[...prLoopSetup(image, loop.engineSetup), ...beforeScript].map((line) => headStep(line)),
551
706
  ...(up.build ? [headStep(up.build[0]!, "Build the templates", { CHANT_ENV: up.env.CHANT_ENV! }), headStep(up.build[1]!, "Lint the project")] : []),
552
707
  // The job's last step: a step after it would start beside whatever the change's code left running.
553
708
  {
@@ -583,7 +738,7 @@ function prEnvGithubJobs(
583
738
  steps: [
584
739
  ...tradeStep,
585
740
  { uses: CHECKOUT, with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
586
- ...runSteps(prLoopSetup(image)),
741
+ ...runSteps(prLoopSetup(image, loop.engineSetup)),
587
742
  ...runSteps(beforeScript),
588
743
  { name, env: j.env, run: j.command },
589
744
  ...runSteps(extraScript),
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `/<product> lock` and `/<product> unlock` (#4588): under `ci.locks: plan`,
3
+ * when the words name the commands, a job answers each comment at the
4
+ * target branch's commit. The lock job keeps the locks the plan took; the
5
+ * unlock job releases them.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { parseYAML } from "@intentius/chant/yaml";
10
+ import type { DriverComponent } from "@intentius/chant/components/driver";
11
+ import { generateGithubPipeline } from "./generate-pipeline";
12
+
13
+ const ESTATE: DriverComponent[] = [
14
+ { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
15
+ ];
16
+
17
+ const WORDS = { product: "plywood", commands: { lock: "lock" as const, unlock: "unlock" as const }, bin: "npx --no-install plywood" };
18
+
19
+ interface Step { name?: string; uses?: string; run?: string; env?: Record<string, string>; with?: Record<string, unknown> }
20
+ interface Job { if?: string; needs?: string[]; permissions: Record<string, string>; steps: Step[] }
21
+ type Doc = { on: Record<string, unknown>; jobs: Record<string, Job> };
22
+
23
+ const gen = (prLoop: Record<string, unknown>) => parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop }).yaml) as Doc;
24
+
25
+ describe("the GitHub workflow's lock and unlock comments", () => {
26
+ const doc = gen({ locks: "plan", words: WORDS });
27
+
28
+ test("a job per comment, started by issue_comment from a commenter who can write", () => {
29
+ expect(Object.keys(doc.jobs)).toEqual(expect.arrayContaining(["comment-lock", "comment-unlock"]));
30
+ expect(doc.on.issue_comment).toEqual({ types: ["created"] });
31
+ const lock = doc.jobs["comment-lock"]!;
32
+ expect(lock.if).toContain("github.event_name == 'issue_comment'");
33
+ expect(lock.if).toContain("startsWith(github.event.comment.body, '/plywood lock')");
34
+ expect(lock.if).toContain("github.event.comment.author_association");
35
+ expect(doc.jobs["comment-unlock"]!.if).toContain("startsWith(github.event.comment.body, '/plywood unlock')");
36
+ });
37
+
38
+ test("the lock keeps the plan's locks through the product's bin, the unlock releases them, at the target branch's commit", () => {
39
+ const lock = doc.jobs["comment-lock"]!;
40
+ expect(lock.steps[0]!.with).toEqual({ "fetch-depth": 0 });
41
+ expect(lock.steps.at(-1)!.run).toBe('npx --no-install plywood lock --keep --pr "$PR_NUMBER" --forge github');
42
+ expect(lock.steps.at(-1)!.env!.PR_NUMBER).toBe("${{ github.event.issue.number }}");
43
+ expect(doc.jobs["comment-unlock"]!.steps.at(-1)!.run).toBe('npx --no-install plywood unlock --pr "$PR_NUMBER"');
44
+ expect(lock.permissions).toEqual({ contents: "write", "pull-requests": "read", statuses: "write" });
45
+ });
46
+
47
+ test("neither job without ci.locks, nor without the words for the commands", () => {
48
+ expect(Object.keys(gen({ words: WORDS }).jobs)).not.toContain("comment-lock");
49
+ const chant = gen({ locks: "plan" });
50
+ expect(Object.keys(chant.jobs)).not.toContain("comment-lock");
51
+ expect(chant.on.issue_comment).toBeUndefined();
52
+ });
53
+ });