@deployfoundation/foundation-deploy 0.2.47 → 0.2.49

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.
package/dist/bin/app.js CHANGED
@@ -8,13 +8,13 @@ import {
8
8
  FoundationPages,
9
9
  FoundationPipeline,
10
10
  NewsletterStack
11
- } from "../chunk-yn8x77x6.js";
11
+ } from "../chunk-6f2hkamj.js";
12
12
  import {
13
13
  adminsFor,
14
14
  instanceNames,
15
15
  loadInstanceFile,
16
16
  provisionsIntegration
17
- } from "../chunk-k6mcv6dk.js";
17
+ } from "../chunk-gnz747z2.js";
18
18
  import"../chunk-dz91avrb.js";
19
19
 
20
20
  // bin/app.ts
@@ -22,12 +22,15 @@ import {
22
22
  slackCommandPrefix,
23
23
  verifyManifest,
24
24
  versionedConfigKey
25
- } from "../chunk-k6mcv6dk.js";
25
+ } from "../chunk-gnz747z2.js";
26
26
  import {
27
27
  DEFAULT_RELEASE_BUCKET,
28
28
  expectedKeyArn
29
29
  } from "../chunk-dz91avrb.js";
30
30
 
31
+ // src/deploy/cli.ts
32
+ import { readFileSync as readFileSync10 } from "node:fs";
33
+
31
34
  // src/deploy/aws.ts
32
35
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
36
  import { tmpdir } from "node:os";
@@ -282,7 +285,7 @@ async function callerAccountId(ctx) {
282
285
  }
283
286
 
284
287
  // src/deploy/config-sync.ts
285
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync2, readdirSync } from "node:fs";
288
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync2, readFileSync as readFileSync3, readdirSync } from "node:fs";
286
289
  import { tmpdir as tmpdir2 } from "node:os";
287
290
  import { join as join3, resolve as resolve3 } from "node:path";
288
291
 
@@ -494,6 +497,11 @@ async function uploadRuntimeConfig(ctx, opts) {
494
497
  if (opts.runtimeConfigPath === undefined && ctx.instance.customization !== undefined)
495
498
  throw new Error("customized instances require a freshly validated runtimeConfigPath; run through `deploy` or `config:sync`");
496
499
  const source = opts.runtimeConfigPath ?? ctx.paths.configPath;
500
+ try {
501
+ parseInstanceConfig(readFileSync3(source, "utf8"));
502
+ } catch (error) {
503
+ throw new Error(`config for ${opts.version} does not parse with this release's schema, so nothing was uploaded: ${error instanceof Error ? error.message : String(error)}`);
504
+ }
497
505
  const key = versionedConfigKey(ctx.names.configKey, opts.version);
498
506
  await awsMutate(ctx, ["s3", "cp", source, `s3://${opts.bucket}/${key}`]);
499
507
  return key;
@@ -908,7 +916,7 @@ var UPWORK_GRAPHQL_DOCUMENTS = Object.freeze({
908
916
  var ETRADE_RESPONSE_MAX_BYTES = 1024 * 1024;
909
917
  var MAX_SECRET_JSON_LENGTH = 16 * 1024;
910
918
  // src/deploy/config.ts
911
- import { readFileSync as readFileSync3 } from "node:fs";
919
+ import { readFileSync as readFileSync4 } from "node:fs";
912
920
  import { parse } from "yaml";
913
921
  function adminsCsv(yamlText, source = "runtime config") {
914
922
  const doc = parse(yamlText);
@@ -922,7 +930,7 @@ function adminsCsv(yamlText, source = "runtime config") {
922
930
  }).join(",");
923
931
  }
924
932
  function adminsCsvFromFile(path) {
925
- return adminsCsv(readFileSync3(path, "utf8"), path);
933
+ return adminsCsv(readFileSync4(path, "utf8"), path);
926
934
  }
927
935
 
928
936
  // src/deploy/endpoint.ts
@@ -1416,7 +1424,7 @@ function composeRuntimeSecret(inputs) {
1416
1424
  import {
1417
1425
  lstatSync,
1418
1426
  mkdirSync as mkdirSync3,
1419
- readFileSync as readFileSync4,
1427
+ readFileSync as readFileSync5,
1420
1428
  realpathSync,
1421
1429
  rmSync as rmSync2,
1422
1430
  statSync,
@@ -1536,9 +1544,9 @@ function stageCustomization(options) {
1536
1544
  const sourceFile = resolvedCustomizationFile(sourceRoot, customization.path);
1537
1545
  if (sourceFile === undefined)
1538
1546
  return migrationFallback(customization.required, log, platformConfigPath);
1539
- const yaml = readFileSync4(sourceFile);
1547
+ const yaml = readFileSync5(sourceFile);
1540
1548
  const tenantConfig = parseInstanceConfig(yaml.toString("utf8"));
1541
- const platformConfig = parseInstanceConfig(readFileSync4(platformConfigPath, "utf8"));
1549
+ const platformConfig = parseInstanceConfig(readFileSync5(platformConfigPath, "utf8"));
1542
1550
  if (tenantConfig.name !== instance.displayName)
1543
1551
  throw new Error("Customization config name must match the selected instance.");
1544
1552
  assertValidTenantJobs(tenantConfig);
@@ -1895,7 +1903,7 @@ async function deploy(ctx, opts) {
1895
1903
  import { createServer } from "node:http";
1896
1904
 
1897
1905
  // src/deploy/github-app-manifest.ts
1898
- import { readFileSync as readFileSync5 } from "node:fs";
1906
+ import { readFileSync as readFileSync6 } from "node:fs";
1899
1907
  import { join as join6 } from "node:path";
1900
1908
  import { parse as parseYaml } from "yaml";
1901
1909
  var GITHUB_APP_MANIFEST_PATH = join6(PACKAGE_ASSETS, "github-app-manifest.yml");
@@ -1916,7 +1924,7 @@ function renderAppManifest(template, instance) {
1916
1924
  });
1917
1925
  }
1918
1926
  function loadAppManifest(instance, template) {
1919
- const text = template ?? readFileSync5(GITHUB_APP_MANIFEST_PATH, "utf8");
1927
+ const text = template ?? readFileSync6(GITHUB_APP_MANIFEST_PATH, "utf8");
1920
1928
  const manifest = parseYaml(renderAppManifest(text, instance));
1921
1929
  if ("default_permissions" in manifest)
1922
1930
  throw new Error("app-manifest.yml: default_permissions are generated from the capability registry; remove them from the template");
@@ -2034,7 +2042,7 @@ async function githubAppCreate(options) {
2034
2042
  }
2035
2043
 
2036
2044
  // src/deploy/job-key.ts
2037
- import { readFileSync as readFileSync6 } from "node:fs";
2045
+ import { readFileSync as readFileSync7 } from "node:fs";
2038
2046
  function jobWebhookOutputKey(jobId) {
2039
2047
  return `JobWebhookUrl${jobId.replace(/[^A-Za-z0-9]/g, "")}`;
2040
2048
  }
@@ -2042,7 +2050,7 @@ function jobsWebhookSecretName(secretPrefix) {
2042
2050
  return `${secretPrefix}/jobs/webhook`;
2043
2051
  }
2044
2052
  async function jobKey(ctx, jobId, options = {}) {
2045
- const config = parseInstanceConfig(readFileSync6(ctx.paths.configPath, "utf8"));
2053
+ const config = parseInstanceConfig(readFileSync7(ctx.paths.configPath, "utf8"));
2046
2054
  const job = config.jobs.find((entry) => entry.id === jobId);
2047
2055
  if (job === undefined)
2048
2056
  throw new Error(`no job "${jobId}" in ${ctx.paths.configPath}`);
@@ -2108,11 +2116,11 @@ async function postDeploy(ctx, opts = {}) {
2108
2116
  }
2109
2117
 
2110
2118
  // src/deploy/setup.ts
2111
- import { existsSync as existsSync5, readFileSync as readFileSync8 } from "node:fs";
2119
+ import { existsSync as existsSync5, readFileSync as readFileSync9 } from "node:fs";
2112
2120
  import { resolve as resolve5 } from "node:path";
2113
2121
 
2114
2122
  // src/deploy/slack-manifest.ts
2115
- import { readFileSync as readFileSync7 } from "node:fs";
2123
+ import { readFileSync as readFileSync8 } from "node:fs";
2116
2124
  import { join as join7 } from "node:path";
2117
2125
  import { parse as parse2 } from "yaml";
2118
2126
  var SLACK_APP_MANIFEST_PATH = join7(PACKAGE_ASSETS, "slack-app-manifest.yml");
@@ -2151,7 +2159,7 @@ function buildSlackManifest(template, instance, env) {
2151
2159
  return manifest;
2152
2160
  }
2153
2161
  function slackManifestFor(instance, env) {
2154
- return buildSlackManifest(readFileSync7(SLACK_APP_MANIFEST_PATH, "utf8"), {
2162
+ return buildSlackManifest(readFileSync8(SLACK_APP_MANIFEST_PATH, "utf8"), {
2155
2163
  displayName: instance.displayName,
2156
2164
  commandPrefix: slackCommandPrefix(instance),
2157
2165
  pages: instance.integrations.pages
@@ -2377,7 +2385,7 @@ async function writeRuntimeSecret(ctx) {
2377
2385
  async function seedCodex(ctx, codexFile) {
2378
2386
  if (!existsSync5(codexFile))
2379
2387
  throw new Error(`${codexFile} not found — sign in locally first, or pass --codex-file`);
2380
- const document = readFileSync8(codexFile, "utf8");
2388
+ const document = readFileSync9(codexFile, "utf8");
2381
2389
  JSON.parse(document);
2382
2390
  await putSecretString(ctx, ctx.names.secretCodex, document);
2383
2391
  console.log(` ${ctx.names.secretCodex} seeded from ${codexFile}`);
@@ -2555,6 +2563,7 @@ Done. Now say \`@${ctx.instance.displayName} hello\` in Slack.`);
2555
2563
 
2556
2564
  // src/deploy/cli.ts
2557
2565
  var COMMANDS = [
2566
+ "check",
2558
2567
  "deploy",
2559
2568
  "post-deploy",
2560
2569
  "config:sync",
@@ -2567,6 +2576,10 @@ var COMMANDS = [
2567
2576
  var USAGE = `usage: foundation-deploy <command> --instance <path> [options]
2568
2577
 
2569
2578
  commands
2579
+ check parse the instance and config files with THIS release's
2580
+ schema; no AWS. Run it on config PRs with the pinned
2581
+ version: a config the deployed release rejects puts the
2582
+ teammate on the fallback config.
2570
2583
  deploy build and push the agent image, cdk deploy --all, refresh
2571
2584
  the runtime secret, sync config
2572
2585
  post-deploy smoke DEFAULT, probe the mount, promote \`live\`, smoke \`live\`
@@ -2645,6 +2658,18 @@ async function main(argv2 = process.argv.slice(2)) {
2645
2658
  ${USAGE}`);
2646
2659
  return 1;
2647
2660
  }
2661
+ if (command === "check") {
2662
+ const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
2663
+ try {
2664
+ parseInstanceConfig(readFileSync10(paths.configPath, "utf8"));
2665
+ } catch (error) {
2666
+ console.error(`${paths.configPath} does not parse with this release (v${toolVersion()}):
2667
+ ${error instanceof Error ? error.message : String(error)}`);
2668
+ return 1;
2669
+ }
2670
+ console.log(`${paths.configPath} parses with this release (v${toolVersion()})`);
2671
+ return 0;
2672
+ }
2648
2673
  if (command === "slack-manifest") {
2649
2674
  const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
2650
2675
  if (process.env.EVENTS_REQUEST_URL === undefined)
@@ -111,6 +111,11 @@ class ReleaseAccountStack extends cdk.Stack {
111
111
  actions: ["ecr:GetAuthorizationToken"],
112
112
  resources: ["*"]
113
113
  }),
114
+ new iam.PolicyStatement({
115
+ sid: "EcrPublicLogin",
116
+ actions: ["ecr-public:GetAuthorizationToken", "sts:GetServiceBearerToken"],
117
+ resources: ["*"]
118
+ }),
114
119
  new iam.PolicyStatement({
115
120
  sid: "SignManifest",
116
121
  actions: ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"],
@@ -24,7 +24,7 @@ import {
24
24
  scheduledJobBindings,
25
25
  slackCommandPrefixes,
26
26
  webhookJobBindings
27
- } from "./chunk-k6mcv6dk.js";
27
+ } from "./chunk-gnz747z2.js";
28
28
 
29
29
  // src/stacks/agent-stack.ts
30
30
  import * as cdk from "aws-cdk-lib";
@@ -259,6 +259,12 @@ class FoundationAgent extends cdk.Stack {
259
259
  resource: "secret",
260
260
  resourceName: `${docsSecretPrefix}/personal/*-??????`,
261
261
  arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
262
+ }),
263
+ cdk.Stack.of(this).formatArn({
264
+ service: "secretsmanager",
265
+ resource: "secret",
266
+ resourceName: `${docsSecretPrefix}/personal-drive/*-??????`,
267
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
262
268
  })
263
269
  ]
264
270
  }));
@@ -382,6 +388,12 @@ class FoundationAgent extends cdk.Stack {
382
388
  this.denyDirectKnockSecretAccess(role, props);
383
389
  this.denyDirectEmailSecretAccess(role, props.secrets.googleEmail, instanceNames(props.instance).secretGoogleEmail);
384
390
  this.denyDirectDocsSecretAccess(role, instanceNames(props.instance).secretGoogleDocs);
391
+ role.addToPolicy(new iam.PolicyStatement({
392
+ sid: "DenySlackSigningSecret",
393
+ effect: iam.Effect.DENY,
394
+ actions: ["secretsmanager:*"],
395
+ resources: [secretArnPattern(props.secrets.signing)]
396
+ }));
385
397
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
386
398
  this.denyDirectEtradeAccess(role, props);
387
399
  this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
@@ -906,6 +918,14 @@ class FoundationApi extends cdk2.Stack {
906
918
  generateStringKey: "placeholder"
907
919
  }
908
920
  }));
921
+ for (const identity of docsIdentities.filter((entry) => entry.readPersonalDrive === true)) {
922
+ docsSecrets.push(new secretsmanager.Secret(this, `PersonalDriveSecret${identity.owner}`, {
923
+ secretName: `${instanceNames(props.instance).secretGoogleDocs}/personal-drive/${identity.owner}`,
924
+ description: `${display} separate read-only personal Drive grant for Slack ${identity.owner}`,
925
+ removalPolicy: cdk2.RemovalPolicy.DESTROY,
926
+ generateSecretString: { secretStringTemplate: "{}", generateStringKey: "placeholder" }
927
+ }));
928
+ }
909
929
  const gatewayRole = new iam2.Role(this, "GatewayRole", {
910
930
  assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
911
931
  description: `${display} gateway Lambda role (least privilege)`,
@@ -1807,7 +1827,7 @@ function configTimezoneFor(configPath) {
1807
1827
  }
1808
1828
  function googleDocsIdentitiesFor(configPath) {
1809
1829
  const docs = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.googleDocs;
1810
- return docs.enabled ? docs.identities.map(({ address, channel, owner }) => ({ address, channel, owner })) : [];
1830
+ return docs.enabled ? docs.identities.map((identity) => ({ ...identity })) : [];
1811
1831
  }
1812
1832
  function emailIdentitiesFor(configPath) {
1813
1833
  const email = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.email;
@@ -369,7 +369,8 @@ var RawGoogleDriveCapability = z.object({
369
369
  var RawGoogleDocsIdentity = z.object({
370
370
  address: z.string().regex(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i),
371
371
  channel: z.string().regex(/^[CGD][A-Z0-9]{8,}$/),
372
- owner: z.string().regex(/^U[A-Z0-9]{8,}$/, "owner must be a Slack U id")
372
+ owner: z.string().regex(/^U[A-Z0-9]{8,}$/, "owner must be a Slack U id"),
373
+ readPersonalDrive: z.boolean().optional()
373
374
  }).strict();
374
375
  var RawGoogleDocsCapability = z.object({
375
376
  enabled: z.boolean().default(false),
@@ -384,6 +385,12 @@ var RawGoogleDocsCapability = z.object({
384
385
  for (const field of ["owner", "channel"]) {
385
386
  const seen = new Set;
386
387
  for (const [index, identity] of value.identities.entries()) {
388
+ if (field === "owner" && identity.readPersonalDrive && !identity.channel.startsWith("D"))
389
+ ctx.addIssue({
390
+ code: z.ZodIssueCode.custom,
391
+ path: ["identities", index, "channel"],
392
+ message: "personal Drive reading requires an owner-bound DM"
393
+ });
387
394
  if (seen.has(identity[field]))
388
395
  ctx.addIssue({
389
396
  code: z.ZodIssueCode.custom,
@@ -675,13 +682,17 @@ var CAPABILITIES = [
675
682
  },
676
683
  {
677
684
  id: "googleDocs",
678
- summary: "Draft Google Docs in a person's own Drive, by personal consent; it reaches only the Docs it created.",
685
+ summary: "Draft Google Docs by personal consent; optionally connect a separate read-only personal Drive grant in the owner's DM.",
679
686
  config: RawGoogleDocsCapability,
680
687
  ...NONE,
681
688
  secrets: ["google/docs", GOOGLE_OAUTH_SECRET],
682
689
  resources: ["googleDocsProxyFunctionName"],
683
- slackCommands: ["docs-connect"],
684
- googleScopes: ["https://www.googleapis.com/auth/drive.file", ...GOOGLE_IDENTITY_SCOPES]
690
+ slackCommands: ["docs-connect", "personal-drive"],
691
+ googleScopes: [
692
+ "https://www.googleapis.com/auth/drive.file",
693
+ "https://www.googleapis.com/auth/drive.readonly",
694
+ ...GOOGLE_IDENTITY_SCOPES
695
+ ]
685
696
  },
686
697
  {
687
698
  id: "googleCalendar",
@@ -1964,4 +1975,4 @@ function ecrRepositoryArn(repositoryUri) {
1964
1975
  return `arn:aws:ecr:${region}:${account}:repository/${name}`;
1965
1976
  }
1966
1977
 
1967
- export { parseSchedule, BASE_SLACK_EVENTS, CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, encodeProactiveChannels, CUSTOMIZATION_ARTIFACT_NAME, newsletterManifestFor, FOUNDATION_VERSION_RE, versionedConfigKey, instanceNames, slackCommandPrefix, slackCommandPrefixes, isScheduledJob, isWebhookJob, webhookJobBindings, scheduledJobBindings, jobWebhookKey, capabilityScheduledJobs, crmOutreachConfigured, crmPolicyFingerprint, parseInstanceConfig, loadInstanceFile, configPathFor, capabilityEnabled, requiresAuthenticatedQueue, enabledIntegrations, provisionsIntegration, adminsFor, WEB_SEARCH_TARGET, WEB_SEARCH_CONNECTOR_VERSION, LIVE_ENDPOINT_NAME, RELEASE_VERSION_RE, parseManifest, manifestKey, lambdaKey, skillsKey, buildManifest, manifestSigningPayload, verifyManifest, lambdaBundleContext, BUN_VERSION, LAMBDA_ENTRY_POINTS, skipBundle, RELEASE_BUCKET_ENV, RELEASE_DIR_ENV, releaseCacheDir, releaseSource, releaseAccess, lambdaCode, agentImage, agentImageTagRequired, releaseImageRepositoryArn, ecrRepositoryArn };
1978
+ export { parseSchedule, BASE_SLACK_EVENTS, CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, isScheduledJob, isWebhookJob, webhookJobBindings, scheduledJobBindings, jobWebhookKey, capabilityScheduledJobs, crmOutreachConfigured, crmPolicyFingerprint, parseInstanceConfig, encodeProactiveChannels, CUSTOMIZATION_ARTIFACT_NAME, newsletterManifestFor, FOUNDATION_VERSION_RE, versionedConfigKey, instanceNames, slackCommandPrefix, slackCommandPrefixes, loadInstanceFile, configPathFor, capabilityEnabled, requiresAuthenticatedQueue, enabledIntegrations, provisionsIntegration, adminsFor, WEB_SEARCH_TARGET, WEB_SEARCH_CONNECTOR_VERSION, LIVE_ENDPOINT_NAME, RELEASE_VERSION_RE, parseManifest, manifestKey, lambdaKey, skillsKey, buildManifest, manifestSigningPayload, verifyManifest, lambdaBundleContext, BUN_VERSION, LAMBDA_ENTRY_POINTS, skipBundle, RELEASE_BUCKET_ENV, RELEASE_DIR_ENV, releaseCacheDir, releaseSource, releaseAccess, lambdaCode, agentImage, agentImageTagRequired, releaseImageRepositoryArn, ecrRepositoryArn };
package/dist/src/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  FoundationPipeline,
11
11
  NewsletterStack,
12
12
  deployStatements
13
- } from "../chunk-yn8x77x6.js";
13
+ } from "../chunk-6f2hkamj.js";
14
14
  import {
15
15
  BUN_VERSION,
16
16
  LAMBDA_ENTRY_POINTS,
@@ -44,7 +44,7 @@ import {
44
44
  skillsKey,
45
45
  skipBundle,
46
46
  verifyManifest
47
- } from "../chunk-k6mcv6dk.js";
47
+ } from "../chunk-gnz747z2.js";
48
48
  import"../chunk-dz91avrb.js";
49
49
  export {
50
50
  BUILD_TIMEOUT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.2.47",
3
+ "version": "0.2.49",
4
4
  "description": "Deploys one Foundation instance from a published, signed release: the CDK app and the deploy tool.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -73,6 +73,10 @@ features:
73
73
  url: ${COMMANDS_REQUEST_URL}
74
74
  description: Let ${DISPLAY_NAME} draft Google Docs in your own Drive
75
75
  should_escape: false
76
+ - command: /${COMMAND_PREFIX}-personal-drive
77
+ url: ${COMMANDS_REQUEST_URL}
78
+ description: Connect your personal Drive read-only in your configured DM
79
+ should_escape: false
76
80
  - command: /${COMMAND_PREFIX}-knock-connect
77
81
  url: ${COMMANDS_REQUEST_URL}
78
82
  description: Connect ${DISPLAY_NAME} to Knock read-only tools (admins)
package/src/deploy/cli.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * tree into a deploy would make every release of the tool carry a model
12
12
  * runtime it never executes.
13
13
  */
14
+ import { readFileSync } from "node:fs";
15
+ import { parseInstanceConfig } from "@deployfoundation/foundation-core";
14
16
  import { type AwsContext, awsContext } from "./aws.ts";
15
17
  import { configSync } from "./config-sync.ts";
16
18
  import { deploy } from "./deploy.ts";
@@ -18,12 +20,13 @@ import { githubAppCreate } from "./github-app-create.ts";
18
20
  import { instanceBanner, loadInstanceContext, resolveInstanceFilePath } from "./instance.ts";
19
21
  import { jobKey } from "./job-key.ts";
20
22
  import { postDeploy } from "./post-deploy.ts";
21
- import { releaseRequest, resolveRelease } from "./release.ts";
23
+ import { releaseRequest, resolveRelease, toolVersion } from "./release.ts";
22
24
  import { setup } from "./setup.ts";
23
25
  import { slackManifestFor } from "./slack-manifest.ts";
24
26
  import { stageCustomization } from "./stage-customization.ts";
25
27
 
26
28
  export const COMMANDS = [
29
+ "check",
27
30
  "deploy",
28
31
  "post-deploy",
29
32
  "config:sync",
@@ -39,6 +42,10 @@ export type Command = (typeof COMMANDS)[number];
39
42
  export const USAGE = `usage: foundation-deploy <command> --instance <path> [options]
40
43
 
41
44
  commands
45
+ check parse the instance and config files with THIS release's
46
+ schema; no AWS. Run it on config PRs with the pinned
47
+ version: a config the deployed release rejects puts the
48
+ teammate on the fallback config.
42
49
  deploy build and push the agent image, cdk deploy --all, refresh
43
50
  the runtime secret, sync config
44
51
  post-deploy smoke DEFAULT, probe the mount, promote \`live\`, smoke \`live\`
@@ -120,6 +127,23 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
120
127
  return 1;
121
128
  }
122
129
 
130
+ // `check` reads files only, so it runs in a pull request with no credentials.
131
+ if (command === "check") {
132
+ const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
133
+ try {
134
+ parseInstanceConfig(readFileSync(paths.configPath, "utf8"));
135
+ } catch (error) {
136
+ console.error(
137
+ `${paths.configPath} does not parse with this release (v${toolVersion()}):\n${
138
+ error instanceof Error ? error.message : String(error)
139
+ }`,
140
+ );
141
+ return 1;
142
+ }
143
+ console.log(`${paths.configPath} parses with this release (v${toolVersion()})`);
144
+ return 0;
145
+ }
146
+
123
147
  // `slack-manifest` writes a machine-readable document to stdout and must not
124
148
  // print a banner into it; every other command announces its target first.
125
149
  if (command === "slack-manifest") {
@@ -17,9 +17,10 @@
17
17
  * checkout's `skills/` otherwise. A company's own skills are a source in its
18
18
  * runtime config (a GitHub repo), never a directory in this package.
19
19
  */
20
- import { existsSync, mkdirSync, mkdtempSync, readdirSync } from "node:fs";
20
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync } from "node:fs";
21
21
  import { tmpdir } from "node:os";
22
22
  import { join, resolve } from "node:path";
23
+ import { parseInstanceConfig } from "@deployfoundation/foundation-core";
23
24
  import { versionedConfigKey } from "@deployfoundation/foundation-core/instance";
24
25
  import { skillsKey } from "../release/manifest.ts";
25
26
  import { type AwsContext, awsMutate, stackOutput } from "./aws.ts";
@@ -130,6 +131,19 @@ export async function uploadRuntimeConfig(
130
131
  "customized instances require a freshly validated runtimeConfigPath; run through `deploy` or `config:sync`",
131
132
  );
132
133
  const source = opts.runtimeConfigPath ?? ctx.paths.configPath;
134
+ // Parse with THIS release's schema before anything is written. Redeploying
135
+ // the live version writes the very object the live runtime reads, so an
136
+ // unparseable upload puts every session on the fallback config even though
137
+ // the deploy then fails at synth (Sky, 2026-10-19: no Upwork for a day).
138
+ try {
139
+ parseInstanceConfig(readFileSync(source, "utf8"));
140
+ } catch (error) {
141
+ throw new Error(
142
+ `config for ${opts.version} does not parse with this release's schema, so nothing was uploaded: ${
143
+ error instanceof Error ? error.message : String(error)
144
+ }`,
145
+ );
146
+ }
133
147
  const key = versionedConfigKey(ctx.names.configKey, opts.version);
134
148
  await awsMutate(ctx, ["s3", "cp", source, `s3://${opts.bucket}/${key}`]);
135
149
  return key;
@@ -672,9 +672,11 @@ export async function deploy(ctx: AwsContext, opts: DeployOptions): Promise<void
672
672
 
673
673
  // The config of the version going in, written BEFORE `cdk deploy` creates
674
674
  // that version, so its pre-warmed containers boot with it rather than with
675
- // the fallback. Nothing else reads this object: the version serving now
676
- // reads its own, which this deploy never touches. A brand-new instance has
677
- // no bucket yet; the config sync after `cdk deploy` covers it.
675
+ // the fallback. When the version going in is the one serving now (a config
676
+ // change on the same release), the live runtime reads this very object, so
677
+ // uploadRuntimeConfig parses it with this release's schema first and refuses
678
+ // rather than write a config the runtime would reject. A brand-new instance
679
+ // has no bucket yet; the config sync after `cdk deploy` covers it.
678
680
  const version = foundationVersion(release);
679
681
  const bucket = phase1
680
682
  ? undefined
@@ -433,6 +433,12 @@ export class FoundationAgent extends cdk.Stack {
433
433
  resourceName: `${docsSecretPrefix}/personal/*-??????`,
434
434
  arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
435
435
  }),
436
+ cdk.Stack.of(this).formatArn({
437
+ service: "secretsmanager",
438
+ resource: "secret",
439
+ resourceName: `${docsSecretPrefix}/personal-drive/*-??????`,
440
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
441
+ }),
436
442
  ],
437
443
  }),
438
444
  );
@@ -629,6 +635,14 @@ export class FoundationAgent extends cdk.Stack {
629
635
  namesFor(props.instance).secretGoogleEmail,
630
636
  );
631
637
  this.denyDirectDocsSecretAccess(role, namesFor(props.instance).secretGoogleDocs);
638
+ role.addToPolicy(
639
+ new iam.PolicyStatement({
640
+ sid: "DenySlackSigningSecret",
641
+ effect: iam.Effect.DENY,
642
+ actions: ["secretsmanager:*"],
643
+ resources: [secretArnPattern(props.secrets.signing)],
644
+ }),
645
+ );
632
646
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
633
647
  this.denyDirectEtradeAccess(role, props);
634
648
  this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
@@ -233,6 +233,16 @@ export class FoundationApi extends cdk.Stack {
233
233
  },
234
234
  }),
235
235
  );
236
+ for (const identity of docsIdentities.filter((entry) => entry.readPersonalDrive === true)) {
237
+ docsSecrets.push(
238
+ new secretsmanager.Secret(this, `PersonalDriveSecret${identity.owner}`, {
239
+ secretName: `${namesFor(props.instance).secretGoogleDocs}/personal-drive/${identity.owner}`,
240
+ description: `${display} separate read-only personal Drive grant for Slack ${identity.owner}`,
241
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
242
+ generateSecretString: { secretStringTemplate: "{}", generateStringKey: "placeholder" },
243
+ }),
244
+ );
245
+ }
236
246
 
237
247
  const gatewayRole = new iam.Role(this, "GatewayRole", {
238
248
  assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
@@ -1495,9 +1505,7 @@ function configTimezoneFor(configPath: string): string {
1495
1505
  /** The Google Docs drafting identities to provision, read from the instance config at synth. */
1496
1506
  export function googleDocsIdentitiesFor(configPath: string): GoogleDocsIdentity[] {
1497
1507
  const docs = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.googleDocs;
1498
- return docs.enabled
1499
- ? docs.identities.map(({ address, channel, owner }) => ({ address, channel, owner }))
1500
- : [];
1508
+ return docs.enabled ? docs.identities.map((identity) => ({ ...identity })) : [];
1501
1509
  }
1502
1510
 
1503
1511
  export function emailIdentitiesFor(configPath: string): EmailIdentity[] {
@@ -208,6 +208,13 @@ export class ReleaseAccountStack extends cdk.Stack {
208
208
  actions: ["ecr:GetAuthorizationToken"],
209
209
  resources: ["*"],
210
210
  }),
211
+ // Pull-only login to public ECR. Anonymous base-image pulls share
212
+ // the hosted runners' quota and failed v0.2.48's build with 429.
213
+ new iam.PolicyStatement({
214
+ sid: "EcrPublicLogin",
215
+ actions: ["ecr-public:GetAuthorizationToken", "sts:GetServiceBearerToken"],
216
+ resources: ["*"],
217
+ }),
211
218
  new iam.PolicyStatement({
212
219
  sid: "SignManifest",
213
220
  actions: ["kms:Sign", "kms:GetPublicKey", "kms:DescribeKey"],