@anthusai/papyrus 1.0.0-next.3 → 1.0.0-next.5
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/amplify/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/globals.css +19 -0
- package/app/layout.tsx +4 -0
- package/app/robots.ts +11 -0
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +72 -16
- package/components/article-video.tsx +112 -3
- package/components/staging-banner.tsx +11 -0
- package/infra/amplify-app-shell.js +178 -80
- package/infra/amplify-app-shell.ts +217 -113
- package/infra/build-specs.js +86 -0
- package/infra/build-specs.ts +132 -0
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +3 -0
- package/infra/site-config.js +155 -0
- package/infra/site-config.ts +200 -0
- package/lib/cached-content-repository.ts +2 -1
- package/lib/content-repository.ts +3 -0
- package/lib/content-source-context.ts +16 -0
- package/lib/graphql-content-repository.ts +182 -37
- package/lib/site-brand.ts +13 -0
- package/lib/site-env.ts +52 -0
- package/lib/staging-gate.ts +37 -0
- package/lib/staging-preview-object.ts +98 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +63 -0
- package/middleware.ts +51 -1
- package/package.json +11 -1
- package/routes.manifest.json +11 -1
- package/scripts/videoml/build-browser-bundle.mjs +22 -0
- package/scripts/videoml/build-preview-bundle.mjs +50 -0
- package/scripts/videoml/bundle-common.mjs +129 -0
- package/scripts/videoml/preview/preview-entry.tsx +381 -0
- package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
- package/scripts/videoml/preview/preview-timing.ts +42 -0
- package/scripts/videoml/preview.html +77 -0
- package/scripts/videoml/standard-entry.tsx +46 -0
|
@@ -1,155 +1,259 @@
|
|
|
1
|
-
export type AmplifyAppShellSiteConfig = {
|
|
2
|
-
/** CDK context selector and stack id suffix. */
|
|
3
|
-
siteId: string;
|
|
4
|
-
/** Amplify app name (also the CloudFormation stack-friendly id). */
|
|
5
|
-
appName: string;
|
|
6
|
-
/** Human description shown in the Amplify console. */
|
|
7
|
-
description: string;
|
|
8
|
-
/** GitHub repo URL the CMS deploys from. */
|
|
9
|
-
repository: string;
|
|
10
|
-
/** Amplify platform. Papyrus CMS is always WEB_COMPUTE (Next.js SSR). */
|
|
11
|
-
platform: "WEB_COMPUTE";
|
|
12
|
-
/** Deploy branch. */
|
|
13
|
-
branchName: string;
|
|
14
|
-
/** Branch environment variables (PAPYRUS_SITE_BRAND etc.). */
|
|
15
|
-
environment: Record<string, string>;
|
|
16
|
-
/**
|
|
17
|
-
* Secrets Manager secret name holding the GitHub access token used by
|
|
18
|
-
* Amplify to clone the repo. CDK reads it via a dynamic reference so the
|
|
19
|
-
* token never lands in the CloudFormation template. Populate the secret
|
|
20
|
-
* before deploying. Replace the initial token with a dedicated fine-grained
|
|
21
|
-
* PAT scoped to this repo.
|
|
22
|
-
*/
|
|
23
|
-
githubTokenSecretName: string;
|
|
24
|
-
/** Custom domain for the CMS (newsroom.<site>.us). Apex stays on the reader. */
|
|
25
|
-
domainName: string;
|
|
26
|
-
/** Route 53 hosted zone id for the domain. */
|
|
27
|
-
hostedZoneId: string;
|
|
28
|
-
/** Build compute type. */
|
|
29
|
-
buildComputeType?: "STANDARD" | "STANDARD_8GB";
|
|
30
|
-
/** Enable branch auto-build so pushes trigger deploys. */
|
|
31
|
-
enableAutoBuild?: boolean;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
1
|
import { Construct } from "constructs";
|
|
35
|
-
import { Stack, StackProps
|
|
2
|
+
import { ArnFormat, CfnOutput, Duration, Stack, StackProps } from "aws-cdk-lib";
|
|
36
3
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
37
4
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
5
|
+
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
6
|
+
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
7
|
+
import { AmplifyAppShellSiteConfig, resolveStagingDomainName } from "./site-config";
|
|
38
8
|
|
|
9
|
+
function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
|
|
10
|
+
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
11
|
+
}
|
|
39
12
|
|
|
40
13
|
/**
|
|
41
|
-
* Provisions the Amplify *app shell*
|
|
14
|
+
* Provisions the Amplify *app shell* of a Papyrus publication from its
|
|
15
|
+
* site.json:
|
|
42
16
|
*
|
|
43
|
-
* -
|
|
44
|
-
*
|
|
45
|
-
* -
|
|
46
|
-
* -
|
|
17
|
+
* - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
|
|
18
|
+
* and `staging` (frontend only, reads the production backend)
|
|
19
|
+
* - for `markus-static` sites, a reader app (WEB) with branch `main`
|
|
20
|
+
* - one domain association per branch/app, IAM service and compute roles
|
|
47
21
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* The GitHub access token is read from Secrets Manager by dynamic reference,
|
|
54
|
-
* so the token value never appears in the synthesized template.
|
|
22
|
+
* No repository and no access token are configured: the apps are manual-deploy
|
|
23
|
+
* until Amplify's GitHub App is connected once in the console
|
|
24
|
+
* (docs/site-hosting.md). The Papyrus backend itself is created inside the CMS
|
|
25
|
+
* app by `ampx pipeline-deploy` from the production build.
|
|
55
26
|
*/
|
|
56
27
|
export class AmplifyAppShellStack extends Stack {
|
|
57
|
-
public readonly
|
|
58
|
-
public readonly
|
|
28
|
+
public readonly cmsAppId: string;
|
|
29
|
+
public readonly readerAppId?: string;
|
|
59
30
|
|
|
60
31
|
constructor(scope: Construct, id: string, config: AmplifyAppShellSiteConfig, props?: StackProps) {
|
|
61
32
|
super(scope, id, props);
|
|
62
33
|
|
|
63
|
-
const
|
|
34
|
+
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
35
|
+
const stagingDomainName = resolveStagingDomainName(config);
|
|
64
36
|
|
|
65
|
-
|
|
66
|
-
// creates AppSync, Storage, Lambda, etc., so it needs broad permissions).
|
|
67
|
-
const serviceRole = new iam.Role(this, "AmplifyServiceRole", {
|
|
37
|
+
const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
|
|
68
38
|
assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
|
|
69
|
-
description: `Backend deploy service role for Amplify app ${
|
|
39
|
+
description: `Backend deploy and staging build service role for Amplify app ${cmsAppName} (AdministratorAccess: documented risk)`,
|
|
70
40
|
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess")],
|
|
71
41
|
});
|
|
72
42
|
|
|
73
|
-
// WEB_COMPUTE (Next.js SSR) requires a compute role for the rendering server.
|
|
74
43
|
const computeRole = new iam.Role(this, "AmplifyComputeRole", {
|
|
75
44
|
assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
|
|
76
|
-
description: `SSR compute role for Amplify app ${
|
|
45
|
+
description: `SSR compute role for Amplify app ${cmsAppName}`,
|
|
77
46
|
managedPolicies: [
|
|
78
47
|
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
79
48
|
iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchLogsFullAccess"),
|
|
80
49
|
],
|
|
81
50
|
});
|
|
82
51
|
|
|
83
|
-
const
|
|
84
|
-
name:
|
|
85
|
-
description: config.
|
|
86
|
-
platform:
|
|
87
|
-
|
|
88
|
-
accessToken: githubToken,
|
|
89
|
-
iamServiceRole: serviceRole.roleArn,
|
|
52
|
+
const cmsApp = new amplify.CfnApp(this, "App", {
|
|
53
|
+
name: cmsAppName,
|
|
54
|
+
description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
|
|
55
|
+
platform: "WEB_COMPUTE",
|
|
56
|
+
iamServiceRole: cmsServiceRole.roleArn,
|
|
90
57
|
computeRoleArn: computeRole.roleArn,
|
|
91
|
-
buildSpec:
|
|
58
|
+
buildSpec: cmsProductionBuildSpec(config),
|
|
59
|
+
jobConfig: config.cms.buildComputeType ? { buildComputeType: config.cms.buildComputeType } : undefined,
|
|
92
60
|
customRules: [{ source: "/<*>", target: "/index.html", status: "404-200" }],
|
|
93
61
|
});
|
|
94
62
|
|
|
95
|
-
|
|
96
|
-
|
|
63
|
+
const stagingMode: Record<string, string> = config.frontend === "markus-static"
|
|
64
|
+
? { PAPYRUS_STAGING_PREVIEW: "static" }
|
|
65
|
+
: { PAPYRUS_CONTENT_SOURCE: "drafts" };
|
|
97
66
|
|
|
98
|
-
const
|
|
99
|
-
appId:
|
|
100
|
-
branchName:
|
|
67
|
+
const productionBranch = new amplify.CfnBranch(this, "Branch", {
|
|
68
|
+
appId: cmsApp.attrAppId,
|
|
69
|
+
branchName: "main",
|
|
101
70
|
stage: "PRODUCTION",
|
|
102
|
-
enableAutoBuild:
|
|
71
|
+
enableAutoBuild: true,
|
|
103
72
|
enablePerformanceMode: false,
|
|
104
|
-
environmentVariables:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
73
|
+
environmentVariables: environmentVariables({
|
|
74
|
+
...config.cms.environment,
|
|
75
|
+
PAPYRUS_SITE_BRAND: config.brand,
|
|
76
|
+
SITE_ENV: "production",
|
|
77
|
+
PAPYRUS_CONTENT_SOURCE: "published",
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
|
|
82
|
+
appId: cmsApp.attrAppId,
|
|
83
|
+
branchName: "staging",
|
|
84
|
+
stage: "BETA",
|
|
85
|
+
enableAutoBuild: true,
|
|
86
|
+
enablePerformanceMode: false,
|
|
87
|
+
buildSpec: cmsStagingBuildSpec(config),
|
|
88
|
+
environmentVariables: environmentVariables({
|
|
89
|
+
...config.cms.environment,
|
|
90
|
+
PAPYRUS_SITE_BRAND: config.brand,
|
|
91
|
+
SITE_ENV: "staging",
|
|
92
|
+
...stagingMode,
|
|
93
|
+
NEXT_PUBLIC_PAPYRUS_STAGING_URL: `https://${stagingDomainName}`,
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const productionDomain = new amplify.CfnDomain(this, "Domain", {
|
|
98
|
+
appId: cmsApp.attrAppId,
|
|
99
|
+
domainName: config.cms.domainName,
|
|
100
|
+
subDomainSettings: [{ branchName: "main", prefix: "" }],
|
|
108
101
|
});
|
|
109
|
-
|
|
102
|
+
productionDomain.addResourceDependency(productionBranch);
|
|
110
103
|
|
|
111
|
-
const
|
|
112
|
-
appId:
|
|
113
|
-
domainName:
|
|
114
|
-
subDomainSettings: [{ branchName:
|
|
104
|
+
const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
|
|
105
|
+
appId: cmsApp.attrAppId,
|
|
106
|
+
domainName: stagingDomainName,
|
|
107
|
+
subDomainSettings: [{ branchName: "staging", prefix: "" }],
|
|
115
108
|
});
|
|
116
|
-
|
|
109
|
+
stagingDomain.addResourceDependency(stagingBranch);
|
|
110
|
+
|
|
111
|
+
this.cmsAppId = cmsApp.attrAppId;
|
|
112
|
+
new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
|
|
113
|
+
new CfnOutput(this, "CmsOrigin", { value: `https://${config.cms.domainName}/` });
|
|
114
|
+
new CfnOutput(this, "StagingOrigin", { value: `https://${stagingDomainName}/` });
|
|
117
115
|
|
|
118
|
-
|
|
116
|
+
if (config.reader) {
|
|
117
|
+
this.readerAppId = this.addReaderApp(config, config.reader, cmsApp.attrAppId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (config.github) {
|
|
121
|
+
this.addGithubCiRole(config, config.github, [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])]);
|
|
122
|
+
}
|
|
119
123
|
}
|
|
120
124
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
125
|
+
private addGithubCiRole(
|
|
126
|
+
config: AmplifyAppShellSiteConfig,
|
|
127
|
+
github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
|
|
128
|
+
amplifyAppIds: string[],
|
|
129
|
+
): void {
|
|
130
|
+
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
|
|
131
|
+
this,
|
|
132
|
+
"GithubOidcProvider",
|
|
133
|
+
this.formatArn({
|
|
134
|
+
service: "iam",
|
|
135
|
+
region: "",
|
|
136
|
+
resource: "oidc-provider",
|
|
137
|
+
resourceName: GITHUB_OIDC_PROVIDER_HOST,
|
|
138
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const ciRole = new iam.Role(this, "GithubCiRole", {
|
|
143
|
+
roleName: `${config.siteId}-github-ci`,
|
|
144
|
+
description: `GitHub Actions role for ${github.owner}/${github.repo} (OIDC, least privilege, no admin policy)`,
|
|
145
|
+
maxSessionDuration: Duration.hours(1),
|
|
146
|
+
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
147
|
+
StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
|
|
148
|
+
StringLike: {
|
|
149
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map(
|
|
150
|
+
(branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`,
|
|
151
|
+
),
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
ciRole.addToPolicy(
|
|
157
|
+
new iam.PolicyStatement({
|
|
158
|
+
sid: "StartAndInspectAmplifyJobs",
|
|
159
|
+
actions: ["amplify:StartJob", "amplify:ListJobs", "amplify:GetJob", "amplify:ListBranches"],
|
|
160
|
+
resources: amplifyAppIds.flatMap((appId) => [
|
|
161
|
+
this.formatArn({ service: "amplify", resource: "apps", resourceName: appId, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
162
|
+
this.formatArn({ service: "amplify", resource: "apps", resourceName: `${appId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
163
|
+
]),
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
ciRole.addToPolicy(
|
|
167
|
+
new iam.PolicyStatement({
|
|
168
|
+
sid: "ReadSiteSecretsForJwtMinting",
|
|
169
|
+
actions: ["ssm:GetParameter", "ssm:GetParameters"],
|
|
170
|
+
resources: [
|
|
171
|
+
this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
172
|
+
this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
173
|
+
],
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
ciRole.addToPolicy(
|
|
177
|
+
new iam.PolicyStatement({
|
|
178
|
+
sid: "WhoAmI",
|
|
179
|
+
actions: ["sts:GetCallerIdentity"],
|
|
180
|
+
resources: ["*"],
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
if (github.ciCanDeployInfra) {
|
|
184
|
+
ciRole.addToPolicy(
|
|
185
|
+
new iam.PolicyStatement({
|
|
186
|
+
sid: "DeployThisSitesAppShellStack",
|
|
187
|
+
actions: ["cloudformation:*"],
|
|
188
|
+
resources: [
|
|
189
|
+
this.formatArn({ service: "cloudformation", resource: "stack", resourceName: `amplify-app-shell-${config.siteId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
190
|
+
],
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
new CfnOutput(this, "GithubCiRoleArn", { value: ciRole.roleArn, description: `Role for GitHub Actions in ${github.owner}/${github.repo}` });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private addReaderApp(
|
|
199
|
+
config: AmplifyAppShellSiteConfig,
|
|
200
|
+
reader: NonNullable<AmplifyAppShellSiteConfig["reader"]>,
|
|
201
|
+
cmsAppId: string,
|
|
202
|
+
): string {
|
|
203
|
+
const readerAppName = reader.appName ?? `${config.siteId}-reader`;
|
|
204
|
+
const readerBranchName = reader.branchName ?? "main";
|
|
205
|
+
|
|
206
|
+
const readerServiceRole = new iam.Role(this, "ReaderServiceRole", {
|
|
207
|
+
assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
|
|
208
|
+
description: `Static reader build role for Amplify app ${readerAppName} (reads the CMS app's SSM secrets and media only)`,
|
|
209
|
+
});
|
|
210
|
+
readerServiceRole.addToPolicy(
|
|
211
|
+
new iam.PolicyStatement({
|
|
212
|
+
sid: "ReadSiteSecretsForJwtMinting",
|
|
213
|
+
actions: ["ssm:GetParameter", "ssm:GetParameters"],
|
|
214
|
+
resources: [
|
|
215
|
+
this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
216
|
+
this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
217
|
+
],
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
readerServiceRole.addToPolicy(
|
|
221
|
+
new iam.PolicyStatement({
|
|
222
|
+
sid: "ReadMediaForExport",
|
|
223
|
+
actions: ["s3:GetObject"],
|
|
224
|
+
resources: ["arn:aws:s3:::amplify-*/media/*"],
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const readerApp = new amplify.CfnApp(this, "ReaderApp", {
|
|
229
|
+
name: readerAppName,
|
|
230
|
+
description: `Papyrus static reader for ${config.siteId} (WEB)`,
|
|
231
|
+
platform: "WEB",
|
|
232
|
+
iamServiceRole: readerServiceRole.roleArn,
|
|
233
|
+
buildSpec: readerBuildSpec(config),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
|
|
237
|
+
appId: readerApp.attrAppId,
|
|
238
|
+
branchName: readerBranchName,
|
|
239
|
+
stage: "PRODUCTION",
|
|
240
|
+
enableAutoBuild: true,
|
|
241
|
+
environmentVariables: environmentVariables({
|
|
242
|
+
...(reader.environment ?? {}),
|
|
243
|
+
PAPYRUS_SITE_BRAND: config.brand,
|
|
244
|
+
SITE_ENV: "production",
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
|
|
249
|
+
appId: readerApp.attrAppId,
|
|
250
|
+
domainName: reader.domainName,
|
|
251
|
+
subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
|
|
252
|
+
});
|
|
253
|
+
readerDomain.addResourceDependency(readerBranch);
|
|
254
|
+
|
|
255
|
+
new CfnOutput(this, "ReaderAppId", { value: readerApp.attrAppId, description: `Amplify app id for ${readerAppName}` });
|
|
256
|
+
new CfnOutput(this, "ReaderOrigin", { value: `https://${reader.domainName}/` });
|
|
257
|
+
return readerApp.attrAppId;
|
|
154
258
|
}
|
|
155
259
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { resolveStoragePreviewPrefix } from "./site-config.js";
|
|
2
|
+
export function papyrusVersionToPep440(version) {
|
|
3
|
+
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z-]+\.(\d+))?$/.exec(version);
|
|
4
|
+
if (!match)
|
|
5
|
+
throw new Error(`cannot map ${version} to PEP 440`);
|
|
6
|
+
return match[2] === undefined ? match[1] : `${match[1]}.dev${match[2]}`;
|
|
7
|
+
}
|
|
8
|
+
function yamlScalar(command) {
|
|
9
|
+
const needsQuoting = /: | #|^[-?:,\[\]{}#&*!|>'"%@`]|:$/.test(command);
|
|
10
|
+
return needsQuoting ? `'${command.replace(/'/g, "''")}'` : command;
|
|
11
|
+
}
|
|
12
|
+
function commandLines(commands, indent) {
|
|
13
|
+
return commands.map((command) => `${indent}- ${yamlScalar(command)}`);
|
|
14
|
+
}
|
|
15
|
+
const NPM_CLEAN_INSTALL = "npm ci --cache .npm --prefer-offline";
|
|
16
|
+
const FRONTEND_ENVIRONMENT_CAPTURE = "env | grep -e '^PAPYRUS_' -e '^SITE_ENV=' -e '^NEXT_PUBLIC_' >> .env.production";
|
|
17
|
+
function pythonProvisioningCommands(config) {
|
|
18
|
+
return [
|
|
19
|
+
"export UV_CACHE_DIR=\"$PWD/.uv-cache\"",
|
|
20
|
+
"curl -LsSf https://astral.sh/uv/install.sh | sh",
|
|
21
|
+
"export PATH=\"$HOME/.local/bin:$PATH\"",
|
|
22
|
+
"uv python install 3.12",
|
|
23
|
+
"uv venv --python 3.12 .venv",
|
|
24
|
+
`uv pip install --python .venv "papyrus-newsroom[markus]==${papyrusVersionToPep440(config.papyrusVersion)}"`,
|
|
25
|
+
"export PATH=\"$PWD/.venv/bin:$PATH\"",
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
function reader(config) {
|
|
29
|
+
if (!config.reader)
|
|
30
|
+
throw new Error(`site ${config.siteId} has no reader app (frontend ${config.frontend})`);
|
|
31
|
+
return config.reader;
|
|
32
|
+
}
|
|
33
|
+
function frontendSection(preBuildCommands, buildCommands, artifactsDirectory, cachePaths) {
|
|
34
|
+
const lines = ["frontend:", " phases:"];
|
|
35
|
+
if (preBuildCommands.length > 0) {
|
|
36
|
+
lines.push(" preBuild:", " commands:", ...commandLines(preBuildCommands, " "));
|
|
37
|
+
}
|
|
38
|
+
lines.push(" build:", " commands:", ...commandLines(buildCommands, " "), " artifacts:", ` baseDirectory: ${artifactsDirectory}`, " files:", " - '**/*'", " cache:", " paths:", ...cachePaths.map((cachePath) => ` - ${cachePath}`));
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
export function cmsProductionBuildSpec(config) {
|
|
42
|
+
return [
|
|
43
|
+
"version: 1",
|
|
44
|
+
"backend:",
|
|
45
|
+
" phases:",
|
|
46
|
+
" build:",
|
|
47
|
+
" commands:",
|
|
48
|
+
...commandLines([
|
|
49
|
+
NPM_CLEAN_INSTALL,
|
|
50
|
+
"if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true; fi",
|
|
51
|
+
"npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID",
|
|
52
|
+
], " "),
|
|
53
|
+
...frontendSection([], [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"], ".next", [".next/cache/**/*", ".npm/**/*"]),
|
|
54
|
+
].join("\n") + "\n";
|
|
55
|
+
}
|
|
56
|
+
export function cmsStagingBuildSpec(config) {
|
|
57
|
+
const preBuild = [NPM_CLEAN_INSTALL, "npx ampx generate outputs --branch main --app-id $AWS_APP_ID"];
|
|
58
|
+
const cachePaths = [".next/cache/**/*", ".npm/**/*"];
|
|
59
|
+
if (config.frontend === "markus-static") {
|
|
60
|
+
const readerConfig = reader(config);
|
|
61
|
+
preBuild.push(...pythonProvisioningCommands(config), "papyrus auth refresh-jwt --write-env .env", "papyrus content export-published --drafts --out content-export --clean", readerConfig.buildCommand, `papyrus content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`);
|
|
62
|
+
cachePaths.push(".uv-cache/**/*");
|
|
63
|
+
}
|
|
64
|
+
return [
|
|
65
|
+
"version: 1",
|
|
66
|
+
...frontendSection(preBuild, [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"], ".next", cachePaths),
|
|
67
|
+
].join("\n") + "\n";
|
|
68
|
+
}
|
|
69
|
+
export function readerBuildSpec(config) {
|
|
70
|
+
const readerConfig = reader(config);
|
|
71
|
+
return [
|
|
72
|
+
"version: 1",
|
|
73
|
+
...frontendSection([
|
|
74
|
+
...pythonProvisioningCommands(config),
|
|
75
|
+
"papyrus auth refresh-jwt --write-env .env",
|
|
76
|
+
"papyrus content export-published --out content-export --clean",
|
|
77
|
+
], [readerConfig.buildCommand], readerConfig.baseDirectory, [".uv-cache/**/*"]),
|
|
78
|
+
].join("\n") + "\n";
|
|
79
|
+
}
|
|
80
|
+
export function buildSpecFor(config, app) {
|
|
81
|
+
if (app === "cms-production")
|
|
82
|
+
return cmsProductionBuildSpec(config);
|
|
83
|
+
if (app === "cms-staging")
|
|
84
|
+
return cmsStagingBuildSpec(config);
|
|
85
|
+
return readerBuildSpec(config);
|
|
86
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { AmplifyAppShellSiteConfig, resolveStoragePreviewPrefix } from "./site-config";
|
|
2
|
+
|
|
3
|
+
export type BuildSpecAppName = "cms-production" | "cms-staging" | "reader";
|
|
4
|
+
|
|
5
|
+
export function papyrusVersionToPep440(version: string): string {
|
|
6
|
+
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z-]+\.(\d+))?$/.exec(version);
|
|
7
|
+
if (!match) throw new Error(`cannot map ${version} to PEP 440`);
|
|
8
|
+
return match[2] === undefined ? match[1] : `${match[1]}.dev${match[2]}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function yamlScalar(command: string): string {
|
|
12
|
+
const needsQuoting = /: | #|^[-?:,\[\]{}#&*!|>'"%@`]|:$/.test(command);
|
|
13
|
+
return needsQuoting ? `'${command.replace(/'/g, "''")}'` : command;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function commandLines(commands: string[], indent: string): string[] {
|
|
17
|
+
return commands.map((command) => `${indent}- ${yamlScalar(command)}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NPM_CLEAN_INSTALL = "npm ci --cache .npm --prefer-offline";
|
|
21
|
+
|
|
22
|
+
const FRONTEND_ENVIRONMENT_CAPTURE =
|
|
23
|
+
"env | grep -e '^PAPYRUS_' -e '^SITE_ENV=' -e '^NEXT_PUBLIC_' >> .env.production";
|
|
24
|
+
|
|
25
|
+
function pythonProvisioningCommands(config: AmplifyAppShellSiteConfig): string[] {
|
|
26
|
+
return [
|
|
27
|
+
"export UV_CACHE_DIR=\"$PWD/.uv-cache\"",
|
|
28
|
+
"curl -LsSf https://astral.sh/uv/install.sh | sh",
|
|
29
|
+
"export PATH=\"$HOME/.local/bin:$PATH\"",
|
|
30
|
+
"uv python install 3.12",
|
|
31
|
+
"uv venv --python 3.12 .venv",
|
|
32
|
+
`uv pip install --python .venv "papyrus-newsroom[markus]==${papyrusVersionToPep440(config.papyrusVersion)}"`,
|
|
33
|
+
"export PATH=\"$PWD/.venv/bin:$PATH\"",
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function reader(config: AmplifyAppShellSiteConfig): NonNullable<AmplifyAppShellSiteConfig["reader"]> {
|
|
38
|
+
if (!config.reader) throw new Error(`site ${config.siteId} has no reader app (frontend ${config.frontend})`);
|
|
39
|
+
return config.reader;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function frontendSection(preBuildCommands: string[], buildCommands: string[], artifactsDirectory: string, cachePaths: string[]): string[] {
|
|
43
|
+
const lines = ["frontend:", " phases:"];
|
|
44
|
+
if (preBuildCommands.length > 0) {
|
|
45
|
+
lines.push(" preBuild:", " commands:", ...commandLines(preBuildCommands, " "));
|
|
46
|
+
}
|
|
47
|
+
lines.push(
|
|
48
|
+
" build:",
|
|
49
|
+
" commands:",
|
|
50
|
+
...commandLines(buildCommands, " "),
|
|
51
|
+
" artifacts:",
|
|
52
|
+
` baseDirectory: ${artifactsDirectory}`,
|
|
53
|
+
" files:",
|
|
54
|
+
" - '**/*'",
|
|
55
|
+
" cache:",
|
|
56
|
+
" paths:",
|
|
57
|
+
...cachePaths.map((cachePath) => ` - ${cachePath}`),
|
|
58
|
+
);
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function cmsProductionBuildSpec(config: AmplifyAppShellSiteConfig): string {
|
|
63
|
+
return [
|
|
64
|
+
"version: 1",
|
|
65
|
+
"backend:",
|
|
66
|
+
" phases:",
|
|
67
|
+
" build:",
|
|
68
|
+
" commands:",
|
|
69
|
+
...commandLines(
|
|
70
|
+
[
|
|
71
|
+
NPM_CLEAN_INSTALL,
|
|
72
|
+
"if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true; fi",
|
|
73
|
+
"npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID",
|
|
74
|
+
],
|
|
75
|
+
" ",
|
|
76
|
+
),
|
|
77
|
+
...frontendSection(
|
|
78
|
+
[],
|
|
79
|
+
[FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"],
|
|
80
|
+
".next",
|
|
81
|
+
[".next/cache/**/*", ".npm/**/*"],
|
|
82
|
+
),
|
|
83
|
+
].join("\n") + "\n";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function cmsStagingBuildSpec(config: AmplifyAppShellSiteConfig): string {
|
|
87
|
+
const preBuild = [NPM_CLEAN_INSTALL, "npx ampx generate outputs --branch main --app-id $AWS_APP_ID"];
|
|
88
|
+
const cachePaths = [".next/cache/**/*", ".npm/**/*"];
|
|
89
|
+
if (config.frontend === "markus-static") {
|
|
90
|
+
const readerConfig = reader(config);
|
|
91
|
+
preBuild.push(
|
|
92
|
+
...pythonProvisioningCommands(config),
|
|
93
|
+
"papyrus auth refresh-jwt --write-env .env",
|
|
94
|
+
"papyrus content export-published --drafts --out content-export --clean",
|
|
95
|
+
readerConfig.buildCommand,
|
|
96
|
+
`papyrus content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`,
|
|
97
|
+
);
|
|
98
|
+
cachePaths.push(".uv-cache/**/*");
|
|
99
|
+
}
|
|
100
|
+
return [
|
|
101
|
+
"version: 1",
|
|
102
|
+
...frontendSection(
|
|
103
|
+
preBuild,
|
|
104
|
+
[FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"],
|
|
105
|
+
".next",
|
|
106
|
+
cachePaths,
|
|
107
|
+
),
|
|
108
|
+
].join("\n") + "\n";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function readerBuildSpec(config: AmplifyAppShellSiteConfig): string {
|
|
112
|
+
const readerConfig = reader(config);
|
|
113
|
+
return [
|
|
114
|
+
"version: 1",
|
|
115
|
+
...frontendSection(
|
|
116
|
+
[
|
|
117
|
+
...pythonProvisioningCommands(config),
|
|
118
|
+
"papyrus auth refresh-jwt --write-env .env",
|
|
119
|
+
"papyrus content export-published --out content-export --clean",
|
|
120
|
+
],
|
|
121
|
+
[readerConfig.buildCommand],
|
|
122
|
+
readerConfig.baseDirectory,
|
|
123
|
+
[".uv-cache/**/*"],
|
|
124
|
+
),
|
|
125
|
+
].join("\n") + "\n";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function buildSpecFor(config: AmplifyAppShellSiteConfig, app: BuildSpecAppName): string {
|
|
129
|
+
if (app === "cms-production") return cmsProductionBuildSpec(config);
|
|
130
|
+
if (app === "cms-staging") return cmsStagingBuildSpec(config);
|
|
131
|
+
return readerBuildSpec(config);
|
|
132
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { CfnOutput, Stack } from "aws-cdk-lib";
|
|
2
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
|
+
export const GITHUB_OIDC_PROVIDER_HOST = "token.actions.githubusercontent.com";
|
|
4
|
+
/**
|
|
5
|
+
* Account-global singleton: AWS allows one IAM OIDC provider per issuer URL per
|
|
6
|
+
* account, so this never belongs in a per-site stack. Deploy it once per
|
|
7
|
+
* account, and only after `aws iam list-open-id-connect-providers` shows no
|
|
8
|
+
* provider for token.actions.githubusercontent.com. When one already exists,
|
|
9
|
+
* do not deploy this stack: per-site CI roles only reference the provider by
|
|
10
|
+
* its ARN (arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com)
|
|
11
|
+
* and work against an existing provider unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export class GithubOidcProviderStack extends Stack {
|
|
14
|
+
providerArn;
|
|
15
|
+
constructor(scope, id, props) {
|
|
16
|
+
super(scope, id, props);
|
|
17
|
+
const provider = new iam.OpenIdConnectProvider(this, "GithubOidc", {
|
|
18
|
+
url: `https://${GITHUB_OIDC_PROVIDER_HOST}`,
|
|
19
|
+
clientIds: ["sts.amazonaws.com"],
|
|
20
|
+
});
|
|
21
|
+
this.providerArn = provider.openIdConnectProviderArn;
|
|
22
|
+
new CfnOutput(this, "GithubOidcProviderArn", { value: provider.openIdConnectProviderArn });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Construct } from "constructs";
|
|
2
|
+
import { CfnOutput, Stack, StackProps } from "aws-cdk-lib";
|
|
3
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
|
+
|
|
5
|
+
export const GITHUB_OIDC_PROVIDER_HOST = "token.actions.githubusercontent.com";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Account-global singleton: AWS allows one IAM OIDC provider per issuer URL per
|
|
9
|
+
* account, so this never belongs in a per-site stack. Deploy it once per
|
|
10
|
+
* account, and only after `aws iam list-open-id-connect-providers` shows no
|
|
11
|
+
* provider for token.actions.githubusercontent.com. When one already exists,
|
|
12
|
+
* do not deploy this stack: per-site CI roles only reference the provider by
|
|
13
|
+
* its ARN (arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com)
|
|
14
|
+
* and work against an existing provider unchanged.
|
|
15
|
+
*/
|
|
16
|
+
export class GithubOidcProviderStack extends Stack {
|
|
17
|
+
public readonly providerArn: string;
|
|
18
|
+
|
|
19
|
+
constructor(scope: Construct, id: string, props?: StackProps) {
|
|
20
|
+
super(scope, id, props);
|
|
21
|
+
|
|
22
|
+
const provider = new iam.OpenIdConnectProvider(this, "GithubOidc", {
|
|
23
|
+
url: `https://${GITHUB_OIDC_PROVIDER_HOST}`,
|
|
24
|
+
clientIds: ["sts.amazonaws.com"],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
this.providerArn = provider.openIdConnectProviderArn;
|
|
28
|
+
new CfnOutput(this, "GithubOidcProviderArn", { value: provider.openIdConnectProviderArn });
|
|
29
|
+
}
|
|
30
|
+
}
|
package/infra/index.js
CHANGED
|
@@ -10,3 +10,6 @@ try {
|
|
|
10
10
|
);
|
|
11
11
|
}
|
|
12
12
|
export const { AmplifyAppShellStack } = await import("./amplify-app-shell.js");
|
|
13
|
+
export const { GithubOidcProviderStack } = await import("./github-oidc-provider.js");
|
|
14
|
+
export { parseSiteConfig } from "./site-config.js";
|
|
15
|
+
export { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec, buildSpecFor } from "./build-specs.js";
|