├── .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 | --------------------------------------------------------------------------------