├── .eslintrc.json
├── .github
├── dependabot.yml
└── workflows
│ └── build-test.yml
├── .gitignore
├── LICENSE
├── README.md
├── action.yml
├── cdk.json
├── gitignore-build
├── images
└── flowchart.png
├── jest.config.js
├── package-lock.json
├── package.json
├── src
├── main.test.ts
├── main.ts
├── stack.ts
├── static-page-stack.test.ts
├── static-page-stack.ts
├── utils.test.ts
└── utils.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["jest", "@typescript-eslint"],
3 | "extends": ["plugin:github/recommended"],
4 | "parser": "@typescript-eslint/parser",
5 | "parserOptions": {
6 | "ecmaVersion": 9,
7 | "sourceType": "module",
8 | "project": "./tsconfig.json"
9 | },
10 | "rules": {
11 | "i18n-text/no-en": "off",
12 | "eslint-comments/no-use": "off",
13 | "import/no-namespace": "off",
14 | "no-unused-vars": "off",
15 | "@typescript-eslint/no-unused-vars": "error",
16 | "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}],
17 | "@typescript-eslint/no-require-imports": "error",
18 | "@typescript-eslint/array-type": "error",
19 | "@typescript-eslint/await-thenable": "error",
20 | "@typescript-eslint/ban-ts-comment": "error",
21 | "camelcase": "off",
22 | "@typescript-eslint/consistent-type-assertions": "error",
23 | "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}],
24 | "@typescript-eslint/func-call-spacing": ["error", "never"],
25 | "@typescript-eslint/no-array-constructor": "error",
26 | "@typescript-eslint/no-empty-interface": "error",
27 | "@typescript-eslint/no-explicit-any": "error",
28 | "@typescript-eslint/no-extraneous-class": "error",
29 | "@typescript-eslint/no-for-in-array": "error",
30 | "@typescript-eslint/no-inferrable-types": "error",
31 | "@typescript-eslint/no-misused-new": "error",
32 | "@typescript-eslint/no-namespace": "error",
33 | "@typescript-eslint/no-non-null-assertion": "warn",
34 | "@typescript-eslint/no-unnecessary-qualifier": "error",
35 | "@typescript-eslint/no-unnecessary-type-assertion": "error",
36 | "@typescript-eslint/no-useless-constructor": "error",
37 | "@typescript-eslint/no-var-requires": "error",
38 | "@typescript-eslint/prefer-for-of": "warn",
39 | "@typescript-eslint/prefer-function-type": "warn",
40 | "@typescript-eslint/prefer-includes": "error",
41 | "@typescript-eslint/prefer-string-starts-ends-with": "error",
42 | "@typescript-eslint/promise-function-async": "error",
43 | "@typescript-eslint/require-array-sort-compare": "error",
44 | "@typescript-eslint/restrict-plus-operands": "error",
45 | "@typescript-eslint/type-annotation-spacing": "error",
46 | "@typescript-eslint/unbound-method": "error"
47 | },
48 | "env": {
49 | "node": true,
50 | "es6": true,
51 | "jest/globals": true
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # Enable version updates for npm
4 | - package-ecosystem: 'npm'
5 | # Look for `package.json` and `lock` files in the `root` directory
6 | directory: '/'
7 | schedule:
8 | interval: 'monthly'
9 |
--------------------------------------------------------------------------------
/.github/workflows/build-test.yml:
--------------------------------------------------------------------------------
1 | name: 'Build, test & publish'
2 | on:
3 | pull_request:
4 | push:
5 |
6 | jobs:
7 | all: # make sure build/ci work properly
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v2
11 | - run: |
12 | npm install
13 | - run: |
14 | npm run all
15 | - name: Publish artifacts
16 | if: github.event_name == 'push' && github.ref == 'refs/heads/master'
17 | run: |
18 | mv gitignore-build .gitignore
19 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
20 | git config user.name "$GITHUB_ACTOR"
21 | git add .
22 | git commit -m "Add build artifacts" > /dev/null
23 | git push origin HEAD:v3.2 -f
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | lib
3 | dist
4 |
5 | # CDK
6 | cdk.context.json
7 |
8 | # CDK asset staging directory
9 | .cdk.staging
10 | cdk.out
11 |
12 | # Parcel default cache directory
13 | .parcel-cache
14 |
15 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020 Onramper
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | Deploy static site to AWS
3 |
4 |
5 |
6 | Batteries-included Github action that deploys a static site to AWS Cloudfront, taking care of DNS, SSL certs and S3 buckets
7 |
8 |
9 |
10 |
11 |
12 | ## Usage
13 | ```yaml
14 | - name: Deploy to AWS
15 | uses: onramper/action-deploy-aws-static-site@v3.2.0
16 | with:
17 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
18 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
19 | domain: subdomain.example.com
20 | publish_dir: ./public
21 | ```
22 |
23 | Make sure to add your `domain` to Route 53 as hosted zone and add an `NS` record if needed. An `A` record will be automatically added by the action.
24 |
--------------------------------------------------------------------------------
/action.yml:
--------------------------------------------------------------------------------
1 | name: 'Deploy static site to AWS'
2 | description: 'Batteries-included Github action that deploys a static site to AWS Cloudfront, taking care of DNS, SSL certs and S3 buckets'
3 | author: 'Onramper'
4 | inputs:
5 | AWS_ACCESS_KEY_ID:
6 | required: true
7 | description: 'The key id of your AWS Credentials'
8 | AWS_SECRET_ACCESS_KEY:
9 | required: true
10 | description: 'The secret key of your AWS Credentials'
11 | domain:
12 | required: true
13 | description: 'Full qualified domain (eg: subdomain.example.com) where the site will be deployed'
14 | publish_dir:
15 | required: true
16 | description: 'Local directory to be published as a static site'
17 | runs:
18 | using: 'node12'
19 | main: 'dist/index.js'
20 | branding:
21 | icon: 'upload-cloud'
22 | color: 'orange'
23 |
--------------------------------------------------------------------------------
/cdk.json:
--------------------------------------------------------------------------------
1 | {
2 | "app": "node ./lib/stack.js",
3 | "context": {}
4 | }
--------------------------------------------------------------------------------
/gitignore-build:
--------------------------------------------------------------------------------
1 | node_modules/*
2 | !node_modules/aws-cdk/
3 | !node_modules/@aws-cdk/
4 | !node_modules/constructs/
5 | !node_modules/aws-cdk-lib/
6 | lib/*.test.js
7 |
8 | # CDK
9 | cdk.context.json
10 |
11 | # CDK asset staging directory
12 | .cdk.staging
13 | cdk.out
14 |
15 | # Parcel default cache directory
16 | .parcel-cache
17 |
18 |
--------------------------------------------------------------------------------
/images/flowchart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onramper/action-deploy-aws-static-site/ab0d08634214b483db18b718cfc66d8dc158cc46/images/flowchart.png
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | clearMocks: true,
3 | moduleFileExtensions: ['js', 'ts'],
4 | testEnvironment: 'node',
5 | testMatch: ['**/src/*.test.ts'],
6 | testRunner: 'jest-circus/runner',
7 | transform: {
8 | '^.+\\.ts$': 'ts-jest'
9 | },
10 | verbose: true
11 | }
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "action-deploy-aws-static-site",
3 | "version": "0.0.0",
4 | "private": true,
5 | "description": "Batteries-included Github action that deploys a static site to AWS Cloudfront, taking care of DNS, SSL certs and S3 buckets",
6 | "main": "lib/main.js",
7 | "scripts": {
8 | "build": "tsc",
9 | "format": "prettier --write src/**/*.ts",
10 | "format-check": "prettier --check src/**/*.ts",
11 | "lint": "eslint src --ext ts --fix",
12 | "package": "ncc build",
13 | "test": "jest",
14 | "deploy": "cdk deploy",
15 | "diff": "cdk diff",
16 | "synth": "cdk synth",
17 | "all": "npm run build && npm run format && npm run lint && npm run package && npm test"
18 | },
19 | "repository": {
20 | "type": "git",
21 | "url": "git+https://github.com/onramper/action-deploy-aws-static-site.git"
22 | },
23 | "keywords": [
24 | "actions",
25 | "node",
26 | "setup"
27 | ],
28 | "author": "",
29 | "license": "MIT",
30 | "dependencies": {
31 | "@actions/core": "^1.10.0",
32 | "@types/istanbul-lib-report": "^3.0.0",
33 | "aws-cdk-lib": "^2.112.0"
34 | },
35 | "devDependencies": {
36 | "@types/jest": "^29.5.5",
37 | "@types/node": "^20.5.9",
38 | "@typescript-eslint/parser": "^6.6.0",
39 | "@vercel/ncc": "^0.38.1",
40 | "aws-cdk": "^2.94.0",
41 | "eslint": "^8.54.0",
42 | "eslint-plugin-github": "^4.10.0",
43 | "eslint-plugin-jest": "^27.2.3",
44 | "jest": "^29.7.0",
45 | "jest-circus": "^29.6.4",
46 | "js-yaml": "^4.1.0",
47 | "prettier": "3.0.3",
48 | "ts-jest": "^29.1.1",
49 | "typescript": "^5.2.2"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/main.test.ts:
--------------------------------------------------------------------------------
1 | import * as process from "process";
2 | /* import * as cp from "child_process";
3 | import * as path from "path"; */
4 |
5 | // shows how the runner will run a javascript action with env / stdout protocol
6 | test("test runs", () => {
7 | process.env["INPUT_DOMAIN"] = "example.com";
8 | process.env["INPUT_PUBLISH_DIR"] = "./images";
9 | process.env["INPUT_AWS_ACCESS_KEY_ID"] = "mock_id";
10 | process.env["INPUT_AWS_SECRET_ACCESS_KEY"] = "mock_secret_key";
11 | process.env["INPUT_CDK_DEFAULT_REGION"] = "mock_default_region";
12 | process.env["GITHUB_WORKSPACE"] = ".";
13 | /* const ip = path.join(__dirname, "..", "lib", "main.js");
14 | const options: cp.ExecSyncOptions = {
15 | env: process.env,
16 | }; */
17 | // No idea how to get past the DNS point
18 | /* expect(() => {
19 | cp.execSync(`node ${ip}`, options).toString();
20 | }).toThrow("The security token included in the request is invalid"); */
21 | // Disabled temporarily: After upgrading the test works locally but there are some issues mocking aws in the Github action.
22 | /* expect(cp.execSync(`node ${ip}`, options).toString()).toMatchInlineSnapshot(`
23 | "::debug::Publishing directory 'images' to 'example.com'
24 | "
25 | `); */
26 | });
27 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import * as core from "@actions/core";
2 | import { execSync } from "child_process";
3 | import * as path from "path";
4 |
5 | function removeLastDir(dirPath: string): string {
6 | return dirPath.split("/").slice(0, -1).join("/");
7 | }
8 |
9 | function execCDK(args: string, env: { [name: string]: string }): void {
10 | execSync(
11 | `(cd ${removeLastDir(__dirname)} && PATH="${removeLastDir(
12 | process.execPath,
13 | )}:$PATH" node node_modules/aws-cdk/bin/cdk.js ${args})`,
14 | {
15 | env,
16 | },
17 | );
18 | }
19 |
20 | async function run(): Promise {
21 | try {
22 | const AWS_ACCESS_KEY_ID: string = core.getInput("AWS_ACCESS_KEY_ID");
23 | const AWS_SECRET_ACCESS_KEY: string = core.getInput(
24 | "AWS_SECRET_ACCESS_KEY",
25 | );
26 | const CDK_DEFAULT_REGION: string = core.getInput("CDK_DEFAULT_REGION");
27 | const domain: string = core.getInput("domain");
28 | if (domain.split(".").length < 2) {
29 | throw new Error(
30 | "Invalid domain, examples of correct domains are 'example.com' or 'subdomain.example.org'",
31 | );
32 | }
33 | const raw_publish_dir: string = core.getInput("publish_dir");
34 | const publish_dir = path.isAbsolute(raw_publish_dir)
35 | ? raw_publish_dir
36 | : path.join(`${process.env.GITHUB_WORKSPACE}`, raw_publish_dir);
37 | core.debug(`Publishing directory '${publish_dir}' to '${domain}'`); // debug is only output if you set the secret `ACTIONS_RUNNER_DEBUG` to true
38 |
39 | const awsCredentials = {
40 | AWS_ACCESS_KEY_ID,
41 | AWS_SECRET_ACCESS_KEY,
42 | CDK_DEFAULT_REGION,
43 | };
44 | execCDK("bootstrap", {
45 | ...awsCredentials,
46 | CDK_DEPLOY_REGION: "us-east-1",
47 | DOMAIN: domain,
48 | FOLDER: publish_dir,
49 | });
50 | execCDK("deploy --require-approval never", {
51 | ...awsCredentials,
52 | DOMAIN: domain,
53 | FOLDER: publish_dir,
54 | });
55 | } catch (error) {
56 | core.setFailed((error as Error).message);
57 | }
58 | }
59 |
60 | run();
61 |
--------------------------------------------------------------------------------
/src/stack.ts:
--------------------------------------------------------------------------------
1 | import * as cdk from "aws-cdk-lib/core";
2 | import { StaticPageStack } from "./static-page-stack";
3 |
4 | const app = new cdk.App();
5 | const { DOMAIN, FOLDER } = process.env;
6 | if (DOMAIN === undefined) {
7 | throw new Error("domain has not been defined");
8 | }
9 | if (FOLDER === undefined) {
10 | throw new Error("publish_dir has not been defined");
11 | }
12 |
13 | new StaticPageStack(app, `StaticPage`, {
14 | stackName: `StaticPage-${DOMAIN}`.split(".").join("-"),
15 | folder: FOLDER,
16 | fullDomain: DOMAIN,
17 | });
18 |
--------------------------------------------------------------------------------
/src/static-page-stack.test.ts:
--------------------------------------------------------------------------------
1 | import { Template } from "aws-cdk-lib/assertions";
2 | import * as cdk from "aws-cdk-lib";
3 | import { StaticPageStack } from "./static-page-stack";
4 |
5 | test("Empty Stack", () => {
6 | const app = new cdk.App();
7 | const stack = new StaticPageStack(app, "MyTestStack", {
8 | fullDomain: "sub.example.com",
9 | folder: "./images",
10 | stackName: "MyTestStack",
11 | });
12 | // Prepare the stack for assertions.
13 | const template = Template.fromStack(stack);
14 | template.hasResource("AWS::S3::Bucket", {});
15 | });
16 |
--------------------------------------------------------------------------------
/src/static-page-stack.ts:
--------------------------------------------------------------------------------
1 | import * as cdk from "aws-cdk-lib/core";
2 | import * as s3 from "aws-cdk-lib/aws-s3";
3 | import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
4 | import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
5 | import {
6 | getDNSZone,
7 | getCertificate,
8 | setDNSRecord,
9 | getSubdomain,
10 | getDomain,
11 | } from "./utils";
12 |
13 | const env = {
14 | // Stack must be in us-east-1, because the ACM certificate for a
15 | // global CloudFront distribution must be requested in us-east-1.
16 | region: "us-east-1",
17 | account: process.env.CDK_DEFAULT_ACCOUNT ?? "mock",
18 | };
19 |
20 | export class StaticPageStack extends cdk.Stack {
21 | constructor(
22 | scope: cdk.App,
23 | id: string,
24 | {
25 | stackName,
26 | folder,
27 | fullDomain,
28 | }: {
29 | stackName: string;
30 | folder: string;
31 | fullDomain: string;
32 | },
33 | ) {
34 | super(scope, id, { stackName, env });
35 |
36 | const subdomain = getSubdomain(fullDomain);
37 | const domain = getDomain(fullDomain);
38 |
39 | const zone = getDNSZone(this, domain);
40 | const certificate = getCertificate(this, fullDomain, zone);
41 |
42 | const websiteBucket = new s3.Bucket(this, "WebsiteBucket", {
43 | websiteIndexDocument: "index.html",
44 | websiteErrorDocument: "error.html",
45 | publicReadAccess: true,
46 | blockPublicAccess: new s3.BlockPublicAccess({
47 | blockPublicAcls: false,
48 | ignorePublicAcls: false,
49 | blockPublicPolicy: false,
50 | restrictPublicBuckets: false,
51 | }),
52 | });
53 |
54 | const distribution = new cloudfront.CloudFrontWebDistribution(
55 | this,
56 | "Distribution",
57 | {
58 | originConfigs: [
59 | {
60 | customOriginSource: {
61 | domainName: websiteBucket.bucketWebsiteDomainName,
62 | originProtocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY,
63 | },
64 | behaviors: [{ isDefaultBehavior: true }],
65 | },
66 | ],
67 | viewerCertificate: certificate,
68 | comment: `CDN for static page on ${fullDomain}`,
69 | priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
70 | },
71 | );
72 |
73 | setDNSRecord(this, subdomain, zone, distribution);
74 |
75 | new s3deploy.BucketDeployment(this, "DeployWithInvalidation", {
76 | sources: [s3deploy.Source.asset(folder)],
77 | destinationBucket: websiteBucket,
78 | distribution,
79 | distributionPaths: ["/*"],
80 | });
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { getSubdomain, getDomain } from "./utils";
2 |
3 | test("getSubdomain", () => {
4 | expect(getSubdomain("example.com")).toBe(null);
5 | expect(getSubdomain("sub.example.com")).toBe("sub");
6 | });
7 |
8 | test("getDomain", () => {
9 | expect(getDomain("sub.example.com")).toBe("example.com");
10 | expect(getDomain("example.com")).toBe("example.com");
11 | });
12 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import * as cdk from "aws-cdk-lib/core";
2 | import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
3 | import * as route53 from "aws-cdk-lib/aws-route53";
4 | import * as targets from "aws-cdk-lib/aws-route53-targets";
5 | import * as acm from "aws-cdk-lib/aws-certificatemanager";
6 |
7 | export function getSubdomain(fullDomain: string): string | null {
8 | const subdomainArray = fullDomain.split(".").slice(0, -2);
9 | if (subdomainArray.length === 0) {
10 | return null;
11 | } else {
12 | return subdomainArray.join(".");
13 | }
14 | }
15 |
16 | export function getDomain(fullDomain: string): string {
17 | return fullDomain.split(".").splice(-2, 2).join(".");
18 | }
19 |
20 | export function getDNSZone(
21 | scope: cdk.Stack,
22 | domainName: string,
23 | ): route53.IHostedZone {
24 | return route53.HostedZone.fromLookup(scope, "Route53Zone", {
25 | domainName,
26 | });
27 | }
28 |
29 | export function getCertificate(
30 | scope: cdk.Stack,
31 | fullDomainName: string,
32 | zone: route53.IHostedZone,
33 | ): cloudfront.ViewerCertificate {
34 | const acmCert = new acm.DnsValidatedCertificate(scope, "SiteCert", {
35 | domainName: fullDomainName,
36 | hostedZone: zone,
37 | });
38 | return cloudfront.ViewerCertificate.fromAcmCertificate(acmCert, {
39 | aliases: [fullDomainName],
40 | });
41 | }
42 |
43 | export function setDNSRecord(
44 | scope: cdk.Stack,
45 | subdomain: string | null,
46 | zone: route53.IHostedZone,
47 | distribution: cloudfront.CloudFrontWebDistribution,
48 | ): route53.ARecord {
49 | return new route53.ARecord(scope, "Alias", {
50 | zone,
51 | target: route53.RecordTarget.fromAlias(
52 | new targets.CloudFrontTarget(distribution),
53 | ),
54 | recordName: subdomain === null ? undefined : subdomain,
55 | });
56 | }
57 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "commonjs",
5 | "outDir": "./lib",
6 | "rootDir": "./src",
7 | "strict": true,
8 | "noImplicitAny": true,
9 | "esModuleInterop": true
10 | },
11 | "exclude": ["node_modules", "cdk.out"]
12 | }
13 |
--------------------------------------------------------------------------------