@catladder/cli 5.6.0 → 5.7.1

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 (65) hide show
  1. package/dist/apps/cli/src/catci.js +22 -0
  2. package/dist/apps/cli/src/catci.js.map +1 -1
  3. package/dist/apps/cli/src/commands/index.d.ts +1 -0
  4. package/dist/apps/cli/src/commands/index.js +4 -1
  5. package/dist/apps/cli/src/commands/index.js.map +1 -1
  6. package/dist/apps/cli/src/commands/project/commandMrPin.d.ts +9 -0
  7. package/dist/apps/cli/src/commands/project/commandMrPin.js +11 -13
  8. package/dist/apps/cli/src/commands/project/commandMrPin.js.map +1 -1
  9. package/dist/apps/cli/src/commands/project/commandMrReviewApp.d.ts +21 -0
  10. package/dist/apps/cli/src/commands/project/commandMrReviewApp.js +102 -0
  11. package/dist/apps/cli/src/commands/project/commandMrReviewApp.js.map +1 -0
  12. package/dist/apps/cli/src/reviewApps/github.d.ts +10 -0
  13. package/dist/apps/cli/src/reviewApps/github.js +240 -0
  14. package/dist/apps/cli/src/reviewApps/github.js.map +1 -0
  15. package/dist/bundles/catci/index.js +5 -5
  16. package/dist/bundles/catenv/index.js +897 -25
  17. package/dist/bundles/cli/index.js +1021 -39
  18. package/dist/bundles/skills/catladder-cli/references/commands.md +13 -0
  19. package/dist/bundles/skills/catladder-config/SKILL.md +4 -0
  20. package/dist/bundles/skills/catladder-deploys/SKILL.md +6 -0
  21. package/dist/bundles/skills/catladder-pipelines/SKILL.md +85 -1
  22. package/dist/packages/pipeline/src/backends/github/GithubBackend.js +60 -3
  23. package/dist/packages/pipeline/src/backends/github/GithubBackend.js.map +1 -1
  24. package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.d.ts +1 -1
  25. package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.js +22 -8
  26. package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.js.map +1 -1
  27. package/dist/packages/pipeline/src/backends/github/reviewDeployWorkflow.d.ts +128 -0
  28. package/dist/packages/pipeline/src/backends/github/reviewDeployWorkflow.js +510 -0
  29. package/dist/packages/pipeline/src/backends/github/reviewDeployWorkflow.js.map +1 -0
  30. package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js +29 -4
  31. package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js.map +1 -1
  32. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.d.ts +11 -1
  33. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js +17 -5
  34. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js.map +1 -1
  35. package/dist/packages/pipeline/src/backends/gitlab/reviewDeployGate.d.ts +26 -0
  36. package/dist/packages/pipeline/src/backends/gitlab/reviewDeployGate.js +94 -0
  37. package/dist/packages/pipeline/src/backends/gitlab/reviewDeployGate.js.map +1 -0
  38. package/dist/packages/pipeline/src/deploy/base/deploy.js +10 -3
  39. package/dist/packages/pipeline/src/deploy/base/deploy.js.map +1 -1
  40. package/dist/packages/pipeline/src/index.d.ts +1 -0
  41. package/dist/packages/pipeline/src/index.js +1 -0
  42. package/dist/packages/pipeline/src/index.js.map +1 -1
  43. package/dist/packages/pipeline/src/pipeline/resolveRequirements.d.ts +2 -0
  44. package/dist/packages/pipeline/src/pipeline/resolveRequirements.js +3 -2
  45. package/dist/packages/pipeline/src/pipeline/resolveRequirements.js.map +1 -1
  46. package/dist/packages/pipeline/src/reviewApps.d.ts +38 -0
  47. package/dist/packages/pipeline/src/reviewApps.js +86 -0
  48. package/dist/packages/pipeline/src/reviewApps.js.map +1 -0
  49. package/dist/packages/pipeline/src/reviewAppsPolicy.d.ts +46 -0
  50. package/dist/packages/pipeline/src/reviewAppsPolicy.js +37 -0
  51. package/dist/packages/pipeline/src/reviewAppsPolicy.js.map +1 -0
  52. package/dist/packages/pipeline/src/types/config.d.ts +55 -0
  53. package/dist/packages/pipeline/src/types/config.js.map +1 -1
  54. package/dist/skills/catladder-cli/references/commands.md +13 -0
  55. package/dist/skills/catladder-config/SKILL.md +4 -0
  56. package/dist/skills/catladder-deploys/SKILL.md +6 -0
  57. package/dist/skills/catladder-pipelines/SKILL.md +85 -1
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/package.json +1 -1
  60. package/src/catci.ts +29 -0
  61. package/src/commands/index.ts +4 -0
  62. package/src/commands/project/commandMrPin.ts +15 -14
  63. package/src/commands/project/commandMrReviewApp.ts +143 -0
  64. package/src/reviewApps/__tests__/github.test.ts +118 -0
  65. package/src/reviewApps/github.ts +307 -0
@@ -2655,6 +2655,8 @@ const aggregateCheckJob_1 = __nccwpck_require__(82145);
2655
2655
  const shippedCatci_1 = __nccwpck_require__(27967);
2656
2656
  const githubReleaseJobs_1 = __nccwpck_require__(25073);
2657
2657
  const scriptFiles_1 = __nccwpck_require__(61071);
2658
+ const reviewApps_1 = __nccwpck_require__(67071);
2659
+ const reviewDeployWorkflow_1 = __nccwpck_require__(73671);
2658
2660
  const WORKFLOWS_FOLDER = ".github/workflows";
2659
2661
  /**
2660
2662
  * generated workflow files are prefixed so that cleanup never touches
@@ -2750,6 +2752,7 @@ class GithubBackend {
2750
2752
  ...(0, ciVariables_1.getGithubInjectedWorkflowEnv)(images.registryImageRef()),
2751
2753
  ...(0, index_1.getPipelineOptions)(config, this.type).runnerVariables,
2752
2754
  };
2755
+ const reviewApps = (0, reviewApps_1.getReviewAppsConfig)(config);
2753
2756
  const reviewStopJobs = {};
2754
2757
  // jobs routed to the per-kind workflow_dispatch workflows (deploy /
2755
2758
  // stop / rollback). A manual job and its build closure live there
@@ -2792,6 +2795,39 @@ class GithubBackend {
2792
2795
  triggerJobs.delete(id);
2793
2796
  }
2794
2797
  }
2798
+ // review delivery chains behind the per-PR deploy switch move to
2799
+ // their own workflow (reviewApps config, or manual review deploys)
2800
+ // When the label/draft policy already decides at the start of the
2801
+ // review run, the chains stay in it behind that condition (one
2802
+ // run, one app build); the review deploy workflow handles whatever
2803
+ // changes later. Manual review deploys only live there.
2804
+ let inRunDeliveryIds = [];
2805
+ const inRunCondition = (0, reviewApps_1.isReviewDeployGated)(reviewApps)
2806
+ ? (0, reviewDeployWorkflow_1.getInRunDeployCondition)(reviewApps)
2807
+ : undefined;
2808
+ if (trigger === "mr") {
2809
+ const deliveryIds = (0, reviewDeployWorkflow_1.getGatedReviewDeliveryJobIds)(triggerJobs, reviewApps);
2810
+ if (deliveryIds.length > 0) {
2811
+ workflows[reviewDeployWorkflow_1.REVIEW_DEPLOY_WORKFLOW_FILE] = (0, reviewDeployWorkflow_1.makeReviewDeployWorkflow)({
2812
+ deliveryIds,
2813
+ triggerJobs,
2814
+ reviewApps,
2815
+ images,
2816
+ workflowEnv,
2817
+ permissions: workflowPermissions(images).permissions,
2818
+ });
2819
+ if (inRunCondition) {
2820
+ inRunDeliveryIds = deliveryIds;
2821
+ for (const id of deliveryIds) {
2822
+ const entry = triggerJobs.get(id);
2823
+ entry.githubJob = (0, reviewDeployWorkflow_1.withInRunCondition)(entry.githubJob, inRunCondition);
2824
+ }
2825
+ }
2826
+ else {
2827
+ deliveryIds.forEach((id) => triggerJobs.delete(id));
2828
+ }
2829
+ }
2830
+ }
2795
2831
  // leaf manual jobs: an explicit manual gate (e.g. a prod deploy) or
2796
2832
  // a stop/rollback task with no automatic trigger. Each becomes a
2797
2833
  // dispatch choice.
@@ -2895,7 +2931,7 @@ class GithubBackend {
2895
2931
  }
2896
2932
  if (Object.keys(jobs).length > 0) {
2897
2933
  const workflowJobs = { ...(0, ensureImageJobs_1.makeEnsureImageGithubJobs)(images), ...jobs };
2898
- workflows[`${GENERATED_FILE_PREFIX}${workflowFileName(trigger)}`] = {
2934
+ const workflow = {
2899
2935
  ...getTriggerWorkflow(trigger),
2900
2936
  ...workflowPermissions(images),
2901
2937
  env: workflowEnv,
@@ -2907,13 +2943,30 @@ class GithubBackend {
2907
2943
  // required. Added at finalization so its needs cover the
2908
2944
  // image-build jobs too.
2909
2945
  jobs: trigger === "mr"
2910
- ? { ...workflowJobs, ...(0, aggregateCheckJob_1.makeAggregateCheckJob)(workflowJobs) }
2946
+ ? {
2947
+ ...workflowJobs,
2948
+ ...(0, aggregateCheckJob_1.makeAggregateCheckJob)(workflowJobs, inRunDeliveryIds),
2949
+ // the stable review app result of an in-run deploy
2950
+ ...(inRunCondition && inRunDeliveryIds.length > 0
2951
+ ? (0, reviewDeployWorkflow_1.makeReviewAppAggregateJob)(inRunDeliveryIds, inRunCondition)
2952
+ : {}),
2953
+ // once green, hand over to the review deploy workflow
2954
+ // when the PR deploys but this run didn't
2955
+ ...(workflows[reviewDeployWorkflow_1.REVIEW_DEPLOY_WORKFLOW_FILE] &&
2956
+ (0, reviewApps_1.isReviewDeployGated)(reviewApps)
2957
+ ? (0, reviewDeployWorkflow_1.makeReviewDeployTriggerJob)(reviewApps, inRunCondition)
2958
+ : {}),
2959
+ }
2911
2960
  : workflowJobs,
2912
2961
  };
2962
+ workflows[`${GENERATED_FILE_PREFIX}${workflowFileName(trigger)}`] =
2963
+ trigger === "mr" && reviewApps.drafts === "skip"
2964
+ ? (0, reviewDeployWorkflow_1.withDraftsSkipped)(workflow)
2965
+ : workflow;
2913
2966
  }
2914
2967
  }
2915
2968
  if (Object.keys(reviewStopJobs).length > 0) {
2916
- workflows[`${GENERATED_FILE_PREFIX}review-stop.yml`] = {
2969
+ const reviewStopWorkflow = {
2917
2970
  name: "🛑 catladder stop review app",
2918
2971
  on: {
2919
2972
  pull_request: { types: ["closed"] },
@@ -2940,6 +2993,10 @@ class GithubBackend {
2940
2993
  },
2941
2994
  jobs: { ...(0, ensureImageJobs_1.makeEnsureImageGithubJobs)(images), ...reviewStopJobs },
2942
2995
  };
2996
+ workflows[`${GENERATED_FILE_PREFIX}review-stop.yml`] = workflows[reviewDeployWorkflow_1.REVIEW_DEPLOY_WORKFLOW_FILE]
2997
+ ? (0, reviewDeployWorkflow_1.withReviewStopSwitch)(reviewStopWorkflow, reviewApps, (0, reviewApps_1.isReviewDeployGated)(reviewApps) &&
2998
+ (0, reviewDeployWorkflow_1.getInRunDeployCondition)(reviewApps) !== undefined)
2999
+ : reviewStopWorkflow;
2943
3000
  }
2944
3001
  for (const [kind, bucket] of Object.entries(manualKindBuckets)) {
2945
3002
  if (Object.keys(bucket.jobs).length === 0) {
@@ -3074,7 +3131,12 @@ exports.AGGREGATE_CHECK_JOB_NAME = "catladder ✅";
3074
3131
  * `success` to `needs` even when red, so they stay non-blocking here
3075
3132
  * by the same mechanism that keeps their workflow green.
3076
3133
  */
3077
- const makeAggregateCheckJob = (reviewJobs) => ({
3134
+ const makeAggregateCheckJob = (reviewJobs,
3135
+ /**
3136
+ * jobs that may be skipped on purpose (review delivery chains behind
3137
+ * the reviewApps switch) — their skip is no failure, their failure is
3138
+ */
3139
+ skippable = []) => ({
3078
3140
  [exports.AGGREGATE_CHECK_JOB_ID]: {
3079
3141
  name: exports.AGGREGATE_CHECK_JOB_NAME,
3080
3142
  "runs-on": "ubuntu-latest",
@@ -3085,13 +3147,22 @@ const makeAggregateCheckJob = (reviewJobs) => ({
3085
3147
  name: exports.AGGREGATE_CHECK_JOB_NAME,
3086
3148
  // the needs context only interpolates inside the workflow file,
3087
3149
  // so this script stays inline
3088
- run: [
3089
- `results='\${{ toJSON(needs) }}'`,
3090
- `echo "$results"`,
3091
- `if echo "$results" | grep -qE '"result": "(failure|cancelled|skipped)"'; then`,
3092
- ` echo "a required job did not succeed"; exit 1`,
3093
- `fi`,
3094
- ].join("\n"),
3150
+ run: (skippable.length === 0
3151
+ ? [
3152
+ `results='\${{ toJSON(needs) }}'`,
3153
+ `echo "$results"`,
3154
+ `if echo "$results" | grep -qE '"result": "(failure|cancelled|skipped)"'; then`,
3155
+ ` echo "a required job did not succeed"; exit 1`,
3156
+ `fi`,
3157
+ ]
3158
+ : [
3159
+ `results='\${{ toJSON(needs) }}'`,
3160
+ `echo "$results"`,
3161
+ `failed=$(echo "$results" | jq -r --arg skippable ' ${[...skippable].sort().join(" ")} ' 'to_entries[] | .key as $job | select(.value.result == "failure" or .value.result == "cancelled" or (.value.result == "skipped" and ($skippable | contains(" " + $job + " ") | not))) | $job')`,
3162
+ `if [ -n "$failed" ]; then`,
3163
+ ` echo "required jobs did not succeed: $failed"; exit 1`,
3164
+ `fi`,
3165
+ ]).join("\n"),
3095
3166
  shell: "bash",
3096
3167
  },
3097
3168
  ],
@@ -4136,6 +4207,523 @@ exports.resolveGithubRegistryImage = resolveGithubRegistryImage;
4136
4207
 
4137
4208
  /***/ }),
4138
4209
 
4210
+ /***/ 73671:
4211
+ /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
4212
+
4213
+ "use strict";
4214
+
4215
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
4216
+ exports.withDraftsSkipped = exports.withReviewStopSwitch = exports.makeReviewDeployTriggerJob = exports.makeReviewAppAggregateJob = exports.REVIEW_APP_STATUS_CONTEXT = exports.REVIEW_APP_AGGREGATE_JOB_NAME = exports.REVIEW_APP_AGGREGATE_JOB_ID = exports.makeReviewDeployWorkflow = exports.getGatedReviewDeliveryJobIds = exports.withInRunCondition = exports.getInRunDeployCondition = exports.REVIEW_DEPLOY_WORKFLOW_FILE = void 0;
4217
+ const shippedCatci_1 = __nccwpck_require__(27967);
4218
+ const resolveRequirements_1 = __nccwpck_require__(81710);
4219
+ const reviewApps_1 = __nccwpck_require__(67071);
4220
+ const aggregateCheckJob_1 = __nccwpck_require__(82145);
4221
+ const ensureImageJobs_1 = __nccwpck_require__(77590);
4222
+ const CATCI = `${shippedCatci_1.GENERATED_CATCI_FOLDER}/index.js`;
4223
+ /**
4224
+ * the generated review deploy workflow. Keep in sync with
4225
+ * GITHUB_REVIEW_DEPLOY_WORKFLOW in apps/cli/src/reviewApps/github.ts.
4226
+ */
4227
+ exports.REVIEW_DEPLOY_WORKFLOW_FILE = "catladder-deploy-review.yml";
4228
+ const GUARD_JOB_ID = "catladder-review-deploy-guard";
4229
+ const TRIGGER_JOB_ID = "catladder-review-deploy-trigger";
4230
+ /** the PR of the run — from the event, or the dispatch input */
4231
+ const PR_NUMBER_EXPRESSION = "github.event.number || inputs.pr";
4232
+ /** quote a value for a github expression string literal */
4233
+ const literal = (value) => `'${value.replaceAll("'", "''")}'`;
4234
+ /** combine a job's existing `if` with another condition */
4235
+ const andIf = (existing, condition) => {
4236
+ if (!existing) {
4237
+ return `\${{ ${condition} }}`;
4238
+ }
4239
+ const unwrapped = existing.replace(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/, "$1");
4240
+ return `\${{ (${unwrapped}) && (${condition}) }}`;
4241
+ };
4242
+ /**
4243
+ * the condition under which the review workflow deploys the review apps
4244
+ * ITSELF, decided from the pull request event at the start of the run
4245
+ * (labels, draft) — the common case then needs one run and one app
4246
+ * build, like gitlab's switch job. Whatever changes later (a label set
4247
+ * during or after the run, a draft marked ready, an explicit request) is
4248
+ * handled by the review deploy workflow. `deploy: "manual"` never
4249
+ * deploys on its own: undefined.
4250
+ */
4251
+ const getInRunDeployCondition = (reviewApps) => {
4252
+ const labels = "github.event.pull_request.labels.*.name";
4253
+ const policy = reviewApps.deploy === "optIn"
4254
+ ? `contains(${labels}, ${literal(reviewApps.label)})`
4255
+ : reviewApps.deploy === "optOut"
4256
+ ? `!contains(${labels}, ${literal(reviewApps.skipLabel)})`
4257
+ : reviewApps.deploy === "auto"
4258
+ ? undefined
4259
+ : null;
4260
+ if (policy === null) {
4261
+ return undefined;
4262
+ }
4263
+ const notDraft = reviewApps.drafts !== "likeReady"
4264
+ ? "!github.event.pull_request.draft"
4265
+ : undefined;
4266
+ return [policy, notDraft].filter(Boolean).join(" && ") || "true";
4267
+ };
4268
+ exports.getInRunDeployCondition = getInRunDeployCondition;
4269
+ /** combine a job's `if` with the in-run deploy condition */
4270
+ const withInRunCondition = (job, condition) => ({
4271
+ ...job,
4272
+ if: andIf(job.if, condition),
4273
+ });
4274
+ exports.withInRunCondition = withInRunCondition;
4275
+ /**
4276
+ * the review delivery jobs (docker → deploy → verify) that move out of
4277
+ * the review workflow into the review deploy workflow:
4278
+ * - all of them when the reviewApps config gates review deploys
4279
+ * - otherwise those of components whose review deploy is manual
4280
+ * (`deploy.when: "manual"`) — github has no manual jobs inside a run,
4281
+ * and the generic manual-task dispatch can't carry the pull request
4282
+ */
4283
+ const getGatedReviewDeliveryJobIds = (triggerJobs, reviewApps) => {
4284
+ var _a;
4285
+ const gatedByConfig = (0, reviewApps_1.isReviewDeployGated)(reviewApps);
4286
+ const manualComponents = new Set([...triggerJobs.values()]
4287
+ .filter(({ job, context }) => (0, reviewApps_1.isReviewDeliveryJob)(context, job) &&
4288
+ job.stage === "deploy" &&
4289
+ job.gate === "manual")
4290
+ .map(({ context }) => context.name));
4291
+ const ids = new Set([...triggerJobs]
4292
+ .filter(([, { job, context }]) => (0, reviewApps_1.isReviewDeliveryJob)(context, job) &&
4293
+ (gatedByConfig || manualComponents.has(context.name)))
4294
+ .map(([id]) => id));
4295
+ // jobs depending on a moved job (e.g. a custom e2e job waiting for a
4296
+ // review deploy) can't stay behind: they move along
4297
+ let grown = ids.size > 0;
4298
+ while (grown) {
4299
+ grown = false;
4300
+ for (const [id, { githubJob }] of triggerJobs) {
4301
+ if (!ids.has(id) && ((_a = githubJob.needs) === null || _a === void 0 ? void 0 : _a.some((need) => ids.has(need)))) {
4302
+ ids.add(id);
4303
+ grown = true;
4304
+ }
4305
+ }
4306
+ }
4307
+ return [...ids];
4308
+ };
4309
+ exports.getGatedReviewDeliveryJobIds = getGatedReviewDeliveryJobIds;
4310
+ /**
4311
+ * the review deploy workflow (`▶️ catladder deploy review`): the review
4312
+ * delivery chains of a pull request, behind a guard that decides
4313
+ * whether this run deploys (see `catci review-app github-guard`).
4314
+ *
4315
+ * Started by
4316
+ * - the review workflow, once it is green and the label/draft policy
4317
+ * says the PR deploys (`source: ci`)
4318
+ * - switching the label on (opt-in: adding it, opt-out: removing the
4319
+ * skip label) or marking a draft ready (`drafts: "checksOnly"`)
4320
+ * - a human or agent dispatching it with the PR number — explicit
4321
+ * requests always deploy, provided the PR's CI is green
4322
+ *
4323
+ * Artifacts are scoped to a single run, so the run rebuilds what the
4324
+ * chains need (the app build); tests are not rerun — the guard checks
4325
+ * the PR's `catladder ✅` instead.
4326
+ */
4327
+ const makeReviewDeployWorkflow = ({ deliveryIds, triggerJobs, reviewApps, images, workflowEnv, permissions, }) => {
4328
+ var _a, _b;
4329
+ const delivery = new Set(deliveryIds);
4330
+ const isQualityJob = (id) => {
4331
+ const entry = triggerJobs.get(id);
4332
+ return !!entry && (0, resolveRequirements_1.providesCapability)(entry.job, "qualityGate");
4333
+ };
4334
+ // the delivery jobs plus everything upstream they need from the
4335
+ // trigger (rebuilt in this run), except the quality jobs
4336
+ const included = new Set();
4337
+ const stack = [...deliveryIds];
4338
+ let current;
4339
+ while ((current = stack.pop()) !== undefined) {
4340
+ if (included.has(current))
4341
+ continue;
4342
+ included.add(current);
4343
+ for (const need of (_b = (_a = triggerJobs.get(current)) === null || _a === void 0 ? void 0 : _a.githubJob.needs) !== null && _b !== void 0 ? _b : []) {
4344
+ if (triggerJobs.has(need) && !isQualityJob(need))
4345
+ stack.push(need);
4346
+ }
4347
+ }
4348
+ // the guard runs first, every other job only when it says deploy
4349
+ const gatedJob = (job) => {
4350
+ var _a;
4351
+ return ({
4352
+ ...job,
4353
+ needs: [GUARD_JOB_ID, ...((_a = job.needs) !== null && _a !== void 0 ? _a : [])],
4354
+ if: andIf(job.if, `needs.${GUARD_JOB_ID}.outputs.deploy == 'true'`),
4355
+ });
4356
+ };
4357
+ const checkoutPrHead = (step) => {
4358
+ var _a;
4359
+ return ((_a = step.uses) === null || _a === void 0 ? void 0 : _a.startsWith("actions/checkout@"))
4360
+ ? {
4361
+ ...step,
4362
+ // the PR head — a label event's default is the merge ref
4363
+ with: {
4364
+ ...step.with,
4365
+ ref: "${{ github.event.pull_request.head.sha || github.sha }}",
4366
+ },
4367
+ }
4368
+ : step;
4369
+ };
4370
+ const chainJobs = Object.fromEntries([...included].sort().map((id) => {
4371
+ const { githubJob } = triggerJobs.get(id);
4372
+ return [
4373
+ id,
4374
+ gatedJob({
4375
+ ...githubJob,
4376
+ // the quality jobs stay in the review workflow — the guard
4377
+ // checks their aggregate instead
4378
+ ...(githubJob.needs
4379
+ ? {
4380
+ needs: githubJob.needs.filter((need) => !isQualityJob(need) || delivery.has(need)),
4381
+ }
4382
+ : {}),
4383
+ steps: githubJob.steps.map(checkoutPrHead),
4384
+ }),
4385
+ ];
4386
+ }));
4387
+ // only the images the chains run in (not e.g. the release image)
4388
+ const neededIds = new Set(Object.values(chainJobs).flatMap((job) => { var _a; return (_a = job.needs) !== null && _a !== void 0 ? _a : []; }));
4389
+ const imageJobs = Object.fromEntries(Object.entries((0, ensureImageJobs_1.makeEnsureImageGithubJobs)(images))
4390
+ .filter(([id]) => neededIds.has(id))
4391
+ .map(([id, job]) => [id, gatedJob(job)]));
4392
+ const switchLabel = (0, reviewApps_1.getReviewAppsSwitchLabel)(reviewApps);
4393
+ const pullRequestTypes = [
4394
+ ...(switchLabel ? [switchLabel.deployOn] : []),
4395
+ ...(reviewApps.drafts === "checksOnly" ? ["ready_for_review"] : []),
4396
+ ];
4397
+ // pull request events that concern the review apps — other label
4398
+ // changes must neither run the guard nor cancel a running deploy
4399
+ const relevantEvent = [
4400
+ "github.event_name == 'workflow_dispatch'",
4401
+ ...(switchLabel
4402
+ ? [
4403
+ `(github.event.action == ${literal(switchLabel.deployOn)} && github.event.label.name == ${literal(switchLabel.label)})`,
4404
+ ]
4405
+ : []),
4406
+ ...(reviewApps.drafts === "checksOnly"
4407
+ ? ["github.event.action == 'ready_for_review'"]
4408
+ : []),
4409
+ ].join(" || ");
4410
+ return {
4411
+ name: "▶️ catladder deploy review",
4412
+ on: {
4413
+ workflow_dispatch: {
4414
+ inputs: {
4415
+ pr: {
4416
+ description: "number of the pull request whose review apps to deploy",
4417
+ required: true,
4418
+ type: "string",
4419
+ },
4420
+ source: {
4421
+ description: "who requests the deploy: manual (always deploys) or ci (applies the label/draft policy)",
4422
+ required: false,
4423
+ type: "choice",
4424
+ options: ["manual", "ci"],
4425
+ default: "manual",
4426
+ },
4427
+ },
4428
+ },
4429
+ ...(pullRequestTypes.length > 0
4430
+ ? { pull_request: { types: pullRequestTypes } }
4431
+ : {}),
4432
+ },
4433
+ concurrency: reviewDeployConcurrency(relevantEvent),
4434
+ permissions,
4435
+ env: { ...workflowEnv, CL_PR_NUMBER: `\${{ ${PR_NUMBER_EXPRESSION} }}` },
4436
+ jobs: {
4437
+ [GUARD_JOB_ID]: {
4438
+ name: "🚦 review deploy guard",
4439
+ "runs-on": "ubuntu-latest",
4440
+ ...(pullRequestTypes.length > 0
4441
+ ? { if: `\${{ ${relevantEvent} }}` }
4442
+ : {}),
4443
+ permissions: {
4444
+ contents: "read",
4445
+ "pull-requests": "read",
4446
+ checks: "read",
4447
+ statuses: "write",
4448
+ },
4449
+ env: {
4450
+ GITHUB_TOKEN: "${{ github.token }}",
4451
+ CL_REVIEW_APPS: JSON.stringify(reviewApps),
4452
+ CL_DEPLOY_SOURCE: "${{ github.event_name == 'workflow_dispatch' && (inputs.source || 'manual') || 'label' }}",
4453
+ CL_HEAD_SHA: "${{ github.event.pull_request.head.sha || github.sha }}",
4454
+ },
4455
+ outputs: { deploy: "${{ steps.main.outputs.deploy }}" },
4456
+ steps: [
4457
+ catciCheckout(),
4458
+ {
4459
+ name: "🚦 review deploy guard",
4460
+ id: "main",
4461
+ run: `node ${CATCI} review-app github-guard`,
4462
+ shell: "bash",
4463
+ },
4464
+ {
4465
+ // the pull request shows the deploy in flight
4466
+ name: "report pending review app",
4467
+ if: "${{ steps.main.outputs.deploy == 'true' }}",
4468
+ env: { GH_TOKEN: "${{ github.token }}" },
4469
+ run: `gh api "repos/$GITHUB_REPOSITORY/statuses/$CL_HEAD_SHA" -f state=pending -f context="${exports.REVIEW_APP_STATUS_CONTEXT}" -f description="deploying the review apps" -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > /dev/null`,
4470
+ shell: "bash",
4471
+ },
4472
+ ],
4473
+ },
4474
+ ...imageJobs,
4475
+ ...chainJobs,
4476
+ ...(0, exports.makeReviewAppAggregateJob)([GUARD_JOB_ID, ...Object.keys(imageJobs), ...Object.keys(chainJobs)], `needs.${GUARD_JOB_ID}.outputs.deploy == 'true'`),
4477
+ },
4478
+ };
4479
+ };
4480
+ exports.makeReviewDeployWorkflow = makeReviewDeployWorkflow;
4481
+ exports.REVIEW_APP_AGGREGATE_JOB_ID = "catladder-review-app-ok";
4482
+ exports.REVIEW_APP_AGGREGATE_JOB_NAME = "catladder review app ✅";
4483
+ /** the commit status context the aggregate job reports */
4484
+ exports.REVIEW_APP_STATUS_CONTEXT = "catladder review app";
4485
+ /**
4486
+ * one job with a STABLE name that succeeds exactly when the review apps
4487
+ * of this run deployed and verified — the result a merge gate (a bot,
4488
+ * or a branch rule for projects that deploy every PR) waits for,
4489
+ * regardless of the project's components.
4490
+ *
4491
+ * It also reports a commit status on the PR head: runs started by a
4492
+ * dispatch (the trigger job, or by hand) are attached to the commit but
4493
+ * not shown on the pull request, a commit status always is.
4494
+ *
4495
+ * Runs only when the run deploys (the guard decided so, or the review
4496
+ * workflow's start condition held) — a run that doesn't deploy says
4497
+ * nothing about the review app (its check is skipped, the commit status
4498
+ * untouched). Neither does a cancelled run (superseded by a newer
4499
+ * deploy, or stopped): a gate keeps waiting instead of seeing red.
4500
+ */
4501
+ const makeReviewAppAggregateJob = (needs,
4502
+ /** when the run deploys the review apps (a github expression) */
4503
+ deploysWhen) => ({
4504
+ [exports.REVIEW_APP_AGGREGATE_JOB_ID]: {
4505
+ name: exports.REVIEW_APP_AGGREGATE_JOB_NAME,
4506
+ "runs-on": "ubuntu-latest",
4507
+ needs: [...needs].sort(),
4508
+ if: `\${{ always() && (${deploysWhen}) }}`,
4509
+ permissions: { statuses: "write" },
4510
+ env: {
4511
+ GH_TOKEN: "${{ github.token }}",
4512
+ HEAD_SHA: "${{ github.event.pull_request.head.sha || github.sha }}",
4513
+ RUN_URL: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
4514
+ },
4515
+ steps: [
4516
+ {
4517
+ name: exports.REVIEW_APP_AGGREGATE_JOB_NAME,
4518
+ // the needs context only interpolates inside the workflow file,
4519
+ // so this script stays inline
4520
+ run: [
4521
+ `results='\${{ toJSON(needs) }}'`,
4522
+ `echo "$results"`,
4523
+ `if echo "$results" | grep -qE '"result": "failure"'; then`,
4524
+ ` state=failure; description="the review apps did not deploy and verify"`,
4525
+ `elif echo "$results" | grep -qE '"result": "cancelled"'; then`,
4526
+ ` # superseded by a newer deploy, or stopped: no result, not red`,
4527
+ ` echo "cancelled — no review app result reported"; exit 0`,
4528
+ `elif echo "$results" | grep -qE '"result": "skipped"'; then`,
4529
+ ` state=failure; description="the review apps did not deploy and verify"`,
4530
+ `else`,
4531
+ ` state=success; description="review apps deployed and verified"`,
4532
+ `fi`,
4533
+ `gh api "repos/$GITHUB_REPOSITORY/statuses/$HEAD_SHA" -f state="$state" -f context="${exports.REVIEW_APP_STATUS_CONTEXT}" -f description="$description" -f target_url="$RUN_URL" > /dev/null`,
4534
+ `echo "$description"`,
4535
+ `[ "$state" = success ]`,
4536
+ ].join("\n"),
4537
+ shell: "bash",
4538
+ },
4539
+ ],
4540
+ },
4541
+ });
4542
+ exports.makeReviewAppAggregateJob = makeReviewAppAggregateJob;
4543
+ /** a checkout of just the materialized catci */
4544
+ const catciCheckout = () => ({
4545
+ name: "Checkout",
4546
+ uses: "actions/checkout@v4",
4547
+ with: { "sparse-checkout": shippedCatci_1.GENERATED_CATCI_FOLDER },
4548
+ });
4549
+ /**
4550
+ * one deploy/stop of a PR's review apps at a time, the newest wins —
4551
+ * shared by the deploy and the stop workflow, so closing a PR or
4552
+ * switching the label off cancels a deploy in flight before tearing
4553
+ * down. Events that don't concern the review apps get a group of
4554
+ * their own, so they never cancel anything.
4555
+ */
4556
+ const reviewDeployConcurrency = (relevantEvent) => ({
4557
+ group: `catladder-review-deploy-\${{ ${PR_NUMBER_EXPRESSION} }}\${{ !(${relevantEvent}) && format('-ignored-{0}', github.run_id) || '' }}`,
4558
+ "cancel-in-progress": true,
4559
+ });
4560
+ /**
4561
+ * the last job of the review workflow: once it is green, dispatch the
4562
+ * review deploy workflow when the label/draft policy says this PR
4563
+ * deploys (read fresh from the PR, so a label set while CI ran counts).
4564
+ * Only for policies that deploy on their own — `deploy: "manual"` waits
4565
+ * for an explicit dispatch.
4566
+ */
4567
+ const makeReviewDeployTriggerJob = (reviewApps,
4568
+ /** the review workflow deployed itself when this held at its start */
4569
+ inRunCondition) => reviewApps.deploy === "manual"
4570
+ ? {}
4571
+ : {
4572
+ [TRIGGER_JOB_ID]: {
4573
+ name: "▶️ trigger review deploy",
4574
+ "runs-on": "ubuntu-latest",
4575
+ needs: [aggregateCheckJob_1.AGGREGATE_CHECK_JOB_ID],
4576
+ ...(inRunCondition
4577
+ ? {
4578
+ // NOT success(): it also checks the transitive needs,
4579
+ // and the chains this run skipped on purpose would make
4580
+ // it false — exactly when the trigger is needed
4581
+ if: `\${{ !cancelled() && needs.${aggregateCheckJob_1.AGGREGATE_CHECK_JOB_ID}.result == 'success' && !(${inRunCondition}) }}`,
4582
+ }
4583
+ : {}),
4584
+ permissions: {
4585
+ contents: "read",
4586
+ "pull-requests": "read",
4587
+ actions: "write",
4588
+ },
4589
+ env: {
4590
+ GITHUB_TOKEN: "${{ github.token }}",
4591
+ CL_REVIEW_APPS: JSON.stringify(reviewApps),
4592
+ CL_HEAD_REF: "${{ github.head_ref }}",
4593
+ CL_HEAD_SHA: "${{ github.event.pull_request.head.sha }}",
4594
+ },
4595
+ steps: [
4596
+ catciCheckout(),
4597
+ {
4598
+ name: "▶️ trigger review deploy",
4599
+ run: `node ${CATCI} review-app github-trigger`,
4600
+ shell: "bash",
4601
+ },
4602
+ ],
4603
+ },
4604
+ };
4605
+ exports.makeReviewDeployTriggerJob = makeReviewDeployTriggerJob;
4606
+ /**
4607
+ * the review stop workflow under a label policy: switching the label
4608
+ * off (opt-in: removing it, opt-out: adding the skip label) stops the
4609
+ * review apps, like closing the PR does. It shares the deploy
4610
+ * workflow's concurrency group, so a deploy in flight is cancelled
4611
+ * first.
4612
+ */
4613
+ const withReviewStopSwitch = (workflow, reviewApps,
4614
+ /** the review workflow deploys itself (see getInRunDeployCondition) */
4615
+ reviewRunDeploys = false) => {
4616
+ const switchLabel = (0, reviewApps_1.getReviewAppsSwitchLabel)(reviewApps);
4617
+ const relevantEvent = [
4618
+ "github.event_name == 'workflow_dispatch'",
4619
+ "github.event.action == 'closed'",
4620
+ ...(switchLabel
4621
+ ? [
4622
+ `(github.event.action == ${literal(switchLabel.stopOn)} && github.event.label.name == ${literal(switchLabel.label)})`,
4623
+ ]
4624
+ : []),
4625
+ ].join(" || ");
4626
+ return {
4627
+ ...workflow,
4628
+ on: {
4629
+ ...workflow.on,
4630
+ pull_request: {
4631
+ types: ["closed", ...(switchLabel ? [switchLabel.stopOn] : [])],
4632
+ },
4633
+ },
4634
+ concurrency: reviewDeployConcurrency(relevantEvent),
4635
+ jobs: Object.fromEntries(Object.entries({
4636
+ ...(reviewRunDeploys ? makeCancelReviewRunsJob() : {}),
4637
+ ...Object.fromEntries(Object.entries(workflow.jobs).map(([id, job]) => {
4638
+ var _a;
4639
+ return [
4640
+ id,
4641
+ // the teardown waits until no review run deploys anymore
4642
+ reviewRunDeploys && !id.startsWith("catladder-image-")
4643
+ ? {
4644
+ ...job,
4645
+ needs: [CANCEL_REVIEW_RUNS_JOB_ID, ...((_a = job.needs) !== null && _a !== void 0 ? _a : [])],
4646
+ }
4647
+ : job,
4648
+ ];
4649
+ })),
4650
+ }).map(([id, job]) => [
4651
+ id,
4652
+ switchLabel ? { ...job, if: andIf(job.if, relevantEvent) } : job,
4653
+ ])),
4654
+ };
4655
+ };
4656
+ exports.withReviewStopSwitch = withReviewStopSwitch;
4657
+ const CANCEL_REVIEW_RUNS_JOB_ID = "catladder-wait-review-runs";
4658
+ /**
4659
+ * the review workflow deploys review apps itself, outside the deploy /
4660
+ * stop concurrency group — a teardown would race a deploy still running
4661
+ * there (the deploy finishing after the stop leaves the app and its
4662
+ * database behind). So the stop first waits for the PR's unfinished
4663
+ * review runs to end — cancelling them when the PR was closed, letting
4664
+ * them finish when only the label was switched off (their CI still
4665
+ * counts). Never fails: a stuck run must not keep the teardown from
4666
+ * running (it gives up after 30 minutes).
4667
+ */
4668
+ const makeCancelReviewRunsJob = () => ({
4669
+ [CANCEL_REVIEW_RUNS_JOB_ID]: {
4670
+ name: "wait for running review deploys",
4671
+ "runs-on": "ubuntu-latest",
4672
+ permissions: { actions: "write", "pull-requests": "read" },
4673
+ env: { GH_TOKEN: "${{ github.token }}" },
4674
+ steps: [
4675
+ {
4676
+ name: "wait for running review deploys",
4677
+ run: [
4678
+ `branch=$(gh pr view "$CL_PR_NUMBER" -R "$GITHUB_REPOSITORY" --json headRefName -q .headRefName) || exit 0`,
4679
+ `runs=$(gh run list -R "$GITHUB_REPOSITORY" --workflow catladder-review.yml --branch "$branch" --limit 20 --json databaseId,status -q '.[] | select(.status != "completed") | .databaseId') || exit 0`,
4680
+ `# a closed PR's CI is worthless: cancel it. Otherwise let the`,
4681
+ `# PR's CI finish (cancelling it would leave catladder ✅ red)`,
4682
+ `if [ "$(jq -r '.action // ""' "$GITHUB_EVENT_PATH")" = closed ]; then`,
4683
+ ` for id in $runs; do echo "cancelling review run $id"; gh run cancel "$id" -R "$GITHUB_REPOSITORY" || true; done`,
4684
+ `fi`,
4685
+ `for id in $runs; do`,
4686
+ ` echo "waiting for review run $id"`,
4687
+ ` for _ in $(seq 1 360); do`,
4688
+ ` [ "$(gh run view "$id" -R "$GITHUB_REPOSITORY" --json status -q .status)" = completed ] && break`,
4689
+ ` sleep 5`,
4690
+ ` done`,
4691
+ `done`,
4692
+ `exit 0`,
4693
+ ].join("\n"),
4694
+ shell: "bash",
4695
+ },
4696
+ ],
4697
+ },
4698
+ });
4699
+ /**
4700
+ * `drafts: "skip"`: draft PRs run nothing — every job of the review
4701
+ * workflow skips them, and marking the PR ready starts the run. A
4702
+ * skipped `catladder ✅` can't let a draft through: drafts can't be
4703
+ * merged.
4704
+ */
4705
+ const withDraftsSkipped = (workflow) => {
4706
+ const on = workflow.on;
4707
+ return {
4708
+ ...workflow,
4709
+ on: {
4710
+ ...workflow.on,
4711
+ pull_request: {
4712
+ ...on.pull_request,
4713
+ types: [...on.pull_request.types, "ready_for_review"],
4714
+ },
4715
+ },
4716
+ jobs: Object.fromEntries(Object.entries(workflow.jobs).map(([id, job]) => [
4717
+ id,
4718
+ { ...job, if: andIf(job.if, "!github.event.pull_request.draft") },
4719
+ ])),
4720
+ };
4721
+ };
4722
+ exports.withDraftsSkipped = withDraftsSkipped;
4723
+ //# sourceMappingURL=reviewDeployWorkflow.js.map
4724
+
4725
+ /***/ }),
4726
+
4139
4727
  /***/ 61071:
4140
4728
  /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
4141
4729
 
@@ -4240,6 +4828,8 @@ const rules_1 = __nccwpck_require__(78503);
4240
4828
  const config_1 = __nccwpck_require__(75591);
4241
4829
  const configruedEnvs_1 = __nccwpck_require__(38866);
4242
4830
  const autoStop_1 = __nccwpck_require__(72792);
4831
+ const reviewApps_1 = __nccwpck_require__(67071);
4832
+ const reviewDeployGate_1 = __nccwpck_require__(3485);
4243
4833
  const createAllJobs_1 = __nccwpck_require__(83477);
4244
4834
  const jobImagesPlan_1 = __nccwpck_require__(59623);
4245
4835
  const shippedCatci_1 = __nccwpck_require__(27967);
@@ -4353,10 +4943,32 @@ class GitlabBackend {
4353
4943
  ...config_1.ALL_PIPELINE_TRIGGERS,
4354
4944
  ...branchTriggers,
4355
4945
  ];
4356
- const jobsPerTrigger = await Promise.all(allTriggers.map(async (trigger) => ({
4357
- trigger,
4358
- jobs: await (0, createGitlabJobs_1.createGitlabJobs)(await (0, createAllJobs_1.createAllJobs)({ config, trigger, pipelineType: this.type }), images, getGitlabRulesForTrigger(trigger)),
4359
- })));
4946
+ const reviewApps = (0, reviewApps_1.getReviewAppsConfig)(config);
4947
+ const jobsPerTrigger = await Promise.all(allTriggers.map(async (trigger) => {
4948
+ const catladderJobs = await (0, createAllJobs_1.createAllJobs)({
4949
+ config,
4950
+ trigger,
4951
+ pipelineType: this.type,
4952
+ });
4953
+ // review deploys behind one switch per MR (reviewApps config)
4954
+ const reviewDeployGate = trigger === "mr"
4955
+ ? (0, reviewDeployGate_1.addGitlabReviewDeployGate)(catladderJobs, reviewApps)
4956
+ : undefined;
4957
+ const jobs = await (0, createGitlabJobs_1.createGitlabJobs)(catladderJobs, images, getGitlabRulesForTrigger(trigger));
4958
+ return {
4959
+ trigger,
4960
+ jobs: reviewDeployGate
4961
+ ? [
4962
+ {
4963
+ name: reviewDeployGate_1.REVIEW_DEPLOY_GATE_JOB_NAME,
4964
+ gitlabJob: reviewDeployGate,
4965
+ context: null,
4966
+ },
4967
+ ...jobs,
4968
+ ]
4969
+ : jobs,
4970
+ };
4971
+ }));
4360
4972
  const allJobsPerTrigger = jobsPerTrigger.flatMap(({ jobs }) => jobs);
4361
4973
  const stages = (0, getPipelineStages_1.getPipelineStages)(config, new Set(allJobsPerTrigger
4362
4974
  .map(({ gitlabJob }) => gitlabJob.stage)
@@ -4436,6 +5048,7 @@ class GitlabBackend {
4436
5048
  },
4437
5049
  branchPipelines: branchTriggers.map(({ branch }) => branch),
4438
5050
  autoStop: (0, autoStop_1.getAutoStopConfig)(config),
5051
+ reviewApps,
4439
5052
  });
4440
5053
  }
4441
5054
  }
@@ -4890,21 +5503,23 @@ function getGitlabNeedsForWorkspaceJob(context, { needs }, allJobs) {
4890
5503
  "use strict";
4891
5504
 
4892
5505
  Object.defineProperty(exports, "__esModule", ({ value: true }));
4893
- exports.createGitlabPipelineWithDefaults = void 0;
5506
+ exports.createGitlabPipelineWithDefaults = exports.DEFAULT_PIPELINE_IMAGE = void 0;
4894
5507
  const bash_1 = __nccwpck_require__(94143);
4895
5508
  const autoStop_1 = __nccwpck_require__(72792);
5509
+ const reviewApps_1 = __nccwpck_require__(67071);
4896
5510
  const rules_1 = __nccwpck_require__(78503);
4897
5511
  /**
4898
5512
  * gitlab requires a top-level default image for jobs that don't set their
4899
5513
  * own. Every catladder-generated job sets an explicit image, so this
4900
- * fallback is never actually pulled — it just needs to be a valid,
5514
+ * fallback is never pulled for them — it just needs to be a valid,
4901
5515
  * build-free image (a catladder job image would drag in a build job).
5516
+ * Trivial jobs (the review deploy switch) reuse it explicitly.
4902
5517
  */
4903
- const DEFAULT_PIPELINE_IMAGE = "node:22";
4904
- const createGitlabPipelineWithDefaults = ({ image, variables, before_script, branchPipelines = [], autoStop, ...config }) => {
5518
+ exports.DEFAULT_PIPELINE_IMAGE = "node:22";
5519
+ const createGitlabPipelineWithDefaults = ({ image, variables, before_script, branchPipelines = [], autoStop, reviewApps, ...config }) => {
4905
5520
  const pinLabel = autoStop && autoStop.pinLabel;
4906
5521
  return {
4907
- image: image !== null && image !== void 0 ? image : DEFAULT_PIPELINE_IMAGE,
5522
+ image: image !== null && image !== void 0 ? image : exports.DEFAULT_PIPELINE_IMAGE,
4908
5523
  variables: {
4909
5524
  FF_USE_FASTZIP: "true", // enable fastzip - a faster zip implementation that also supports level configuration.
4910
5525
  ARTIFACT_COMPRESSION_LEVEL: "fast", // we value speed over compression
@@ -4942,6 +5557,16 @@ const createGitlabPipelineWithDefaults = ({ image, variables, before_script, bra
4942
5557
  PIPELINE_NAME: "Thinking...",
4943
5558
  },
4944
5559
  },
5560
+ // reviewApps.drafts: "skip" — draft MRs get no pipeline (rules
5561
+ // stop at the first match, so this must come first)
5562
+ ...((reviewApps === null || reviewApps === void 0 ? void 0 : reviewApps.drafts) === "skip"
5563
+ ? [
5564
+ {
5565
+ if: `${rules_1.RULE_IS_MERGE_REQUEST.if} && ${reviewApps_1.GITLAB_DRAFT_MR_CONDITION}`,
5566
+ when: "never",
5567
+ },
5568
+ ]
5569
+ : []),
4945
5570
  // pinned MRs (rules stop at the first match, so this must come
4946
5571
  // before the plain merge-request rule)
4947
5572
  ...(pinLabel
@@ -5309,6 +5934,107 @@ exports.getReleaseGateJobNames = getReleaseGateJobNames;
5309
5934
 
5310
5935
  /***/ }),
5311
5936
 
5937
+ /***/ 3485:
5938
+ /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
5939
+
5940
+ "use strict";
5941
+
5942
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
5943
+ exports.addGitlabReviewDeployGate = exports.REVIEW_DEPLOY_GATE_JOB_NAME = void 0;
5944
+ const reviewApps_1 = __nccwpck_require__(67071);
5945
+ const rules_1 = __nccwpck_require__(78503);
5946
+ const createGitlabPipeline_1 = __nccwpck_require__(32060);
5947
+ exports.REVIEW_DEPLOY_GATE_JOB_NAME = "🚀 deploy review";
5948
+ /**
5949
+ * the review deploy switch of a merge request (see reviewApps.ts): ONE
5950
+ * job per MR pipeline that unlocks the delivery chains (docker → deploy
5951
+ * → verify) of all components' review apps, instead of a manual job per
5952
+ * component — or a docker build per component in every pipeline.
5953
+ *
5954
+ * The chain heads `needs` the switch. It is a no-op job with
5955
+ * `allow_failure: true`: while it is unplayed, gitlab marks its
5956
+ * dependents skipped and the pipeline still passes ("Pipelines must
5957
+ * succeed" keeps working); playing it resets them to created, so it
5958
+ * can be played any time — early clicks wait for the builds and tests
5959
+ * like the chains always did (the same mechanism as the release
5960
+ * button).
5961
+ *
5962
+ * The label/draft policy decides whether it starts on its own. Label
5963
+ * changes only apply to the next pipeline (gitlab doesn't start one for
5964
+ * them) — `catladder mr review-app-on` sets the label and triggers one.
5965
+ *
5966
+ * Mutates the needs of the gated jobs; returns the switch job, or
5967
+ * nothing when review deploys aren't gated (or nothing deploys).
5968
+ */
5969
+ const addGitlabReviewDeployGate = (allJobs, reviewApps) => {
5970
+ var _a;
5971
+ if (!(0, reviewApps_1.isReviewDeployGated)(reviewApps)) {
5972
+ return undefined;
5973
+ }
5974
+ let gated = false;
5975
+ for (const { context, jobs } of allJobs.components) {
5976
+ for (const job of jobs) {
5977
+ // the chain heads: verify follows its deploy anyway
5978
+ if ((0, reviewApps_1.isReviewDeliveryJob)(context, job) && job.stage !== "verify") {
5979
+ job.needs = [
5980
+ ...((_a = job.needs) !== null && _a !== void 0 ? _a : []),
5981
+ { job: exports.REVIEW_DEPLOY_GATE_JOB_NAME, artifacts: false, global: true },
5982
+ ];
5983
+ gated = true;
5984
+ }
5985
+ }
5986
+ }
5987
+ if (!gated) {
5988
+ return undefined;
5989
+ }
5990
+ const isMr = rules_1.RULE_IS_MERGE_REQUEST.if;
5991
+ const manual = { when: "manual", allow_failure: true };
5992
+ const auto = { when: "on_success" };
5993
+ const policyRules = reviewApps.deploy === "optIn"
5994
+ ? [
5995
+ {
5996
+ if: `${isMr} && $CI_MERGE_REQUEST_LABELS =~ ${(0, reviewApps_1.getGitlabLabelRegex)(reviewApps.label)}`,
5997
+ ...auto,
5998
+ },
5999
+ { if: isMr, ...manual },
6000
+ ]
6001
+ : reviewApps.deploy === "optOut"
6002
+ ? [
6003
+ {
6004
+ if: `${isMr} && $CI_MERGE_REQUEST_LABELS =~ ${(0, reviewApps_1.getGitlabLabelRegex)(reviewApps.skipLabel)}`,
6005
+ ...manual,
6006
+ },
6007
+ { if: isMr, ...auto },
6008
+ ]
6009
+ : reviewApps.deploy === "manual"
6010
+ ? [{ if: isMr, ...manual }]
6011
+ : [{ if: isMr, ...auto }];
6012
+ return {
6013
+ stage: "setup",
6014
+ image: createGitlabPipeline_1.DEFAULT_PIPELINE_IMAGE,
6015
+ variables: { GIT_STRATEGY: "none" },
6016
+ needs: [],
6017
+ interruptible: true,
6018
+ allow_failure: true,
6019
+ script: [
6020
+ `echo "review apps unlocked — the docker → deploy → verify chains of all components run as soon as their builds and tests succeeded"`,
6021
+ ],
6022
+ rules: [
6023
+ rules_1.RULE_NEVER_ON_AGENT_TRIGGER,
6024
+ // drafts never deploy on their own (drafts: "skip" has no
6025
+ // pipeline at all, see the workflow rules)
6026
+ ...(reviewApps.drafts !== "likeReady"
6027
+ ? [{ if: `${isMr} && ${reviewApps_1.GITLAB_DRAFT_MR_CONDITION}`, ...manual }]
6028
+ : []),
6029
+ ...policyRules,
6030
+ ],
6031
+ };
6032
+ };
6033
+ exports.addGitlabReviewDeployGate = addGitlabReviewDeployGate;
6034
+ //# sourceMappingURL=reviewDeployGate.js.map
6035
+
6036
+ /***/ }),
6037
+
5312
6038
  /***/ 21444:
5313
6039
  /***/ ((__unused_webpack_module, exports) => {
5314
6040
 
@@ -9033,6 +9759,7 @@ const jobs_1 = __nccwpck_require__(98521);
9033
9759
  const utils_1 = __nccwpck_require__(71286);
9034
9760
  const autoStop_1 = __nccwpck_require__(72792);
9035
9761
  const stop_1 = __nccwpck_require__(35937);
9762
+ const reviewApps_1 = __nccwpck_require__(67071);
9036
9763
  const variables_1 = __nccwpck_require__(54768);
9037
9764
  exports.DEPLOY_JOB_NAME = "🚀 Deploy";
9038
9765
  class DeployJob extends jobs_1.CatladderJob {
@@ -9069,9 +9796,15 @@ class DeployJob extends jobs_1.CatladderJob {
9069
9796
  : ((_b = context.componentConfig.env) === null || _b === void 0 ? void 0 : _b.stage) === false
9070
9797
  ? "auto" // is prod, but no staging, auto deploy
9071
9798
  : "manual"; // manually deploy
9072
- const whenDeploy = whenDeployDefined
9073
- ? whenDeployDefined
9074
- : whenDeployDefault;
9799
+ // gated review apps: one switch per MR/PR unlocks the whole delivery
9800
+ // chain (see reviewApps.ts), the deploy itself then runs on its own
9801
+ const isGatedReviewApp = context.environment.instance.type === "review" &&
9802
+ (0, reviewApps_1.isReviewDeployGated)((0, reviewApps_1.getReviewAppsConfig)(context.fullConfig));
9803
+ const whenDeploy = isGatedReviewApp
9804
+ ? "auto"
9805
+ : whenDeployDefined
9806
+ ? whenDeployDefined
9807
+ : whenDeployDefault;
9075
9808
  // wait for the deployment of other components first
9076
9809
  const waitForRequirements = (_d = (_c = deployConfig === null || deployConfig === void 0 ? void 0 : deployConfig.waitFor) === null || _c === void 0 ? void 0 : _c.map((c) => ({
9077
9810
  capability: "deployment",
@@ -11909,6 +12642,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
11909
12642
  Object.defineProperty(exports, "__esModule", ({ value: true }));
11910
12643
  __exportStar(__nccwpck_require__(46734), exports);
11911
12644
  __exportStar(__nccwpck_require__(72792), exports);
12645
+ __exportStar(__nccwpck_require__(67071), exports);
11912
12646
  __exportStar(__nccwpck_require__(62853), exports);
11913
12647
  __exportStar(__nccwpck_require__(63557), exports);
11914
12648
  __exportStar(__nccwpck_require__(78503), exports);
@@ -13040,7 +13774,7 @@ exports.projectFileExists = projectFileExists;
13040
13774
  "use strict";
13041
13775
 
13042
13776
  Object.defineProperty(exports, "__esModule", ({ value: true }));
13043
- exports.resolveRequirements = void 0;
13777
+ exports.resolveRequirements = exports.providesCapability = void 0;
13044
13778
  /**
13045
13779
  * jobs that don't declare `provides` get a capability derived from
13046
13780
  * their stage. This keeps e.g. custom jobs in the test stage acting
@@ -13071,6 +13805,7 @@ const providesCapability = (job, capability) => {
13071
13805
  ((_a = CAPABILITY_IMPLICATIONS[c]) !== null && _a !== void 0 ? _a : []).includes(capability);
13072
13806
  });
13073
13807
  };
13808
+ exports.providesCapability = providesCapability;
13074
13809
  /**
13075
13810
  * the planner: resolves the semantic `requires` declarations of all jobs
13076
13811
  * to concrete job dependencies (`needs`), now that all jobs of the
@@ -13112,7 +13847,7 @@ const expandRequirement = (context, job, requirement, allJobs) => {
13112
13847
  c.context.env === env;
13113
13848
  })
13114
13849
  .flatMap((c) => c.jobs);
13115
- const providers = candidates.filter((candidate) => candidate !== job && providesCapability(candidate, capability));
13850
+ const providers = candidates.filter((candidate) => candidate !== job && (0, exports.providesCapability)(candidate, capability));
13116
13851
  // consuming artifacts from nobody is almost always a broken config
13117
13852
  const isStrict = (_b = strict !== null && strict !== void 0 ? strict : artifacts) !== null && _b !== void 0 ? _b : false;
13118
13853
  if (isStrict && providers.length === 0) {
@@ -13426,6 +14161,143 @@ exports.getReleaseMethod = getReleaseMethod;
13426
14161
 
13427
14162
  /***/ }),
13428
14163
 
14164
+ /***/ 67071:
14165
+ /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
14166
+
14167
+ "use strict";
14168
+
14169
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14170
+ if (k2 === undefined) k2 = k;
14171
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14172
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14173
+ desc = { enumerable: true, get: function() { return m[k]; } };
14174
+ }
14175
+ Object.defineProperty(o, k2, desc);
14176
+ }) : (function(o, m, k, k2) {
14177
+ if (k2 === undefined) k2 = k;
14178
+ o[k2] = m[k];
14179
+ }));
14180
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14181
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
14182
+ };
14183
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
14184
+ exports.isReviewDeliveryJob = exports.GITLAB_DRAFT_MR_CONDITION = exports.getGitlabLabelRegex = exports.getReviewAppsSwitchLabel = exports.isReviewDeployGated = exports.getReviewAppsConfig = void 0;
14185
+ const autoStop_1 = __nccwpck_require__(72792);
14186
+ const reviewAppsPolicy_1 = __nccwpck_require__(49821);
14187
+ __exportStar(__nccwpck_require__(49821), exports);
14188
+ const getReviewAppsConfig = (config) => {
14189
+ var _a, _b, _c, _d, _e, _f, _g, _h;
14190
+ return ({
14191
+ deploy: (_b = (_a = config.reviewApps) === null || _a === void 0 ? void 0 : _a.deploy) !== null && _b !== void 0 ? _b : "auto",
14192
+ // single colon on purpose: gitlab treats `a::b` labels as scoped and
14193
+ // mutually exclusive, which would make this label and the
14194
+ // `catladder::pin-review` pin label displace each other
14195
+ label: (_d = (_c = config.reviewApps) === null || _c === void 0 ? void 0 : _c.label) !== null && _d !== void 0 ? _d : reviewAppsPolicy_1.DEFAULT_REVIEW_APP_LABEL,
14196
+ skipLabel: (_f = (_e = config.reviewApps) === null || _e === void 0 ? void 0 : _e.skipLabel) !== null && _f !== void 0 ? _f : reviewAppsPolicy_1.DEFAULT_NO_REVIEW_APP_LABEL,
14197
+ drafts: (_h = (_g = config.reviewApps) === null || _g === void 0 ? void 0 : _g.drafts) !== null && _h !== void 0 ? _h : "likeReady",
14198
+ });
14199
+ };
14200
+ exports.getReviewAppsConfig = getReviewAppsConfig;
14201
+ /**
14202
+ * whether the review deploys sit behind the per-MR/PR deploy switch
14203
+ * instead of running in every pipeline
14204
+ */
14205
+ const isReviewDeployGated = (config) => config.deploy !== "auto" || config.drafts === "checksOnly";
14206
+ exports.isReviewDeployGated = isReviewDeployGated;
14207
+ /**
14208
+ * the label whose change switches the review apps on (`labeled` on
14209
+ * opt-in, `unlabeled` on opt-out) or off (the inverse), if the mode is
14210
+ * label-driven at all
14211
+ */
14212
+ const getReviewAppsSwitchLabel = (config) => {
14213
+ switch (config.deploy) {
14214
+ case "optIn":
14215
+ return { label: config.label, deployOn: "labeled", stopOn: "unlabeled" };
14216
+ case "optOut":
14217
+ return {
14218
+ label: config.skipLabel,
14219
+ deployOn: "unlabeled",
14220
+ stopOn: "labeled",
14221
+ };
14222
+ default:
14223
+ return undefined;
14224
+ }
14225
+ };
14226
+ exports.getReviewAppsSwitchLabel = getReviewAppsSwitchLabel;
14227
+ /**
14228
+ * gitlab: matches a label inside `$CI_MERGE_REQUEST_LABELS`
14229
+ * (comma-separated) — anchored so `x` never matches `no-x`
14230
+ */
14231
+ const getGitlabLabelRegex = (label) => `/${(0, autoStop_1.getPinLabelRegexSource)(label)}/`;
14232
+ exports.getGitlabLabelRegex = getGitlabLabelRegex;
14233
+ /**
14234
+ * gitlab: a draft MR, by its title prefix — the only draft signal every
14235
+ * gitlab version exposes to rules
14236
+ */
14237
+ exports.GITLAB_DRAFT_MR_CONDITION = "$CI_MERGE_REQUEST_TITLE =~ /^\\s*(\\[draft\\]|\\(draft\\)|draft:)/i";
14238
+ /**
14239
+ * whether a job belongs to the delivery chain of a review app — the
14240
+ * docker image, the deploy and the post-deploy verification. These jobs
14241
+ * sit behind the review deploy switch; quality jobs (lint, test, audit)
14242
+ * and the app build never do.
14243
+ */
14244
+ const isReviewDeliveryJob = (context, job) => {
14245
+ var _a;
14246
+ return context.type === "component" &&
14247
+ context.environment.instance.type === "review" &&
14248
+ (job.stage === "deploy" ||
14249
+ job.stage === "verify" ||
14250
+ ((_a = job.provides) !== null && _a !== void 0 ? _a : []).includes("dockerImage"));
14251
+ };
14252
+ exports.isReviewDeliveryJob = isReviewDeliveryJob;
14253
+ //# sourceMappingURL=reviewApps.js.map
14254
+
14255
+ /***/ }),
14256
+
14257
+ /***/ 49821:
14258
+ /***/ ((__unused_webpack_module, exports) => {
14259
+
14260
+ "use strict";
14261
+
14262
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
14263
+ exports.shouldAutoDeployReviewApps = exports.DEFAULT_NO_REVIEW_APP_LABEL = exports.DEFAULT_REVIEW_APP_LABEL = void 0;
14264
+ exports.DEFAULT_REVIEW_APP_LABEL = "catladder:review-app";
14265
+ exports.DEFAULT_NO_REVIEW_APP_LABEL = "catladder:no-review-app";
14266
+ /**
14267
+ * whether the review apps of an MR/PR in the given state deploy
14268
+ * automatically. An explicit request (playing the gate job, dispatching
14269
+ * the deploy workflow) always deploys and doesn't consult this.
14270
+ */
14271
+ const shouldAutoDeployReviewApps = (config, { labels, draft }) => {
14272
+ if (draft && config.drafts !== "likeReady") {
14273
+ return { deploy: false, reason: "drafts don't deploy review apps" };
14274
+ }
14275
+ switch (config.deploy) {
14276
+ case "auto":
14277
+ return { deploy: true, reason: "review apps deploy automatically" };
14278
+ case "manual":
14279
+ return {
14280
+ deploy: false,
14281
+ reason: "review apps only deploy on request (deploy: manual)",
14282
+ };
14283
+ case "optIn":
14284
+ return labels.includes(config.label)
14285
+ ? { deploy: true, reason: `the '${config.label}' label is set` }
14286
+ : { deploy: false, reason: `the '${config.label}' label is not set` };
14287
+ case "optOut":
14288
+ return labels.includes(config.skipLabel)
14289
+ ? { deploy: false, reason: `the '${config.skipLabel}' label is set` }
14290
+ : {
14291
+ deploy: true,
14292
+ reason: `the '${config.skipLabel}' label is not set`,
14293
+ };
14294
+ }
14295
+ };
14296
+ exports.shouldAutoDeployReviewApps = shouldAutoDeployReviewApps;
14297
+ //# sourceMappingURL=reviewAppsPolicy.js.map
14298
+
14299
+ /***/ }),
14300
+
13429
14301
  /***/ 78503:
13430
14302
  /***/ ((__unused_webpack_module, exports) => {
13431
14303