Repository: ai/ssdeploy Branch: main Commit: cd65ade8bc5f Files: 32 Total size: 38.1 KB Directory structure: gitextract_gk28koth/ ├── .editorconfig ├── .github/ │ └── workflows/ │ └── test.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin.js ├── configs/ │ ├── .dockerignore │ ├── Dockerfile │ ├── close.yml │ ├── deploy.yml │ ├── nginx-template.conf │ └── preview.yml ├── lib/ │ ├── build.js │ ├── call-cloudflare.js │ ├── changed.js │ ├── debug.js │ ├── deploy.js │ ├── detect-docker.js │ ├── dirs.js │ ├── find-package-up.js │ ├── init.js │ ├── purge.js │ ├── run-image.js │ ├── show-error.js │ ├── show-help.js │ ├── show-spinner.js │ ├── show-version.js │ └── sign.js ├── package.json └── purge.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: push: branches: - main pull_request: permissions: contents: read jobs: test: name: Test runs-on: ubuntu-latest steps: - name: Checkout the repository uses: actions/checkout@v3 - name: Install pnpm uses: pnpm/action-setup@v2 with: version: 7.29.3 - name: Install Node.js uses: actions/setup-node@v3 with: node-version: 18 cache: pnpm - name: Install all dependencies run: pnpm install --frozen-lockfile - name: Run tests run: pnpm test ================================================ FILE: .gitignore ================================================ node_modules/ ================================================ FILE: .npmignore ================================================ pnpm-lock.yaml example.png ================================================ FILE: CHANGELOG.md ================================================ # Change Log This project adheres to [Semantic Versioning](http://semver.org/). ## 0.9.3 * Move to the new Github Actions output API. ## 0.9.2 * Fixed sending all traffic to latest revision. ## 0.9.1 * Fixed `run` command for server with custom `Dockerfile` and without `dist`. ## 0.9 * Added `preview` command. * Added `close` preview. * Added GitHub Deployment to CI. * Added workflows for preview pull requests. ## 0.8.1 * Updated `dotenv`. * Updated GitHub Actions config example. ## 0.8 * Added `pnpm` support. ## 0.7.2 * Replaced `nanocolors` with `piococolors`. * Reduced package size. ## 0.7.1 * Replaced `colorette` with `nanocolors`. ## 0.7 * Dropped Node.js 13 support. * Reduced dependencies. ## 0.6.21 * Speed-up Docker build. ## 0.6.20 * Updated workflow config. * Removed `ora`. ## 0.6.19 * Update deploy workflow example. ## 0.6.18 * Fix `sign` command. ## 0.6.17 * Added color output to CI on deploy. ## 0.6.16 * Fixed `.avif` support. ## 0.6.15 * Fixed `colorette` imports. ## 0.6.14 * Replace color output library. ## 0.6.13 * Reduce dependencies. ## 0.6.12 * Fix Node.js 14 support. ## 0.6.11 * Improve image downloading output. ## 0.6.10 * Use `stderr` for debug messages. ## 0.6.9 * Remove color from GitHub Actions output variable. ## 0.6.8 * Fix GitHub Actions output variable. ## 0.6.7 * Fix mark to check `ssdeploy changed` hash. ## 0.6.6 * Use configs in `ssdeploy changed` hash. * Remove white spaces from `WEBSITE_URL` environment variable too. * Fix GitHub Actions output variable. * Fix text in `ssdeploy changed`. ## 0.6.5 * Fix error message. ## 0.6.4 * Fix loading previous hash in `ssdeploy changed`. ## 0.6.3 * Fix syntax error in GitHub Actions workflow. ## 0.6.2 * Clean up GitHub Actions workflow. ## 0.6.1 * Fix GitHub Actions workflow for `ssdeploy changed`. ## 0.6 * Do not spend resources to deploy website if files were not changed. * Fix colors on GitHub Actions. ## 0.5.1 * Fix `trim()` error on default region environment variable. ## 0.5 * Add `npm` support. * Add `--verbose` by default to deploy workflow. * Fix error on new line and spaces in environment variables. ## 0.4.2 * Fix deploy without previous images to clean. ## 0.4.1 * Show step number during the build. ## 0.4 * Rename `node_modules/ssdeploy/purge` to `node_modules/ssdeploy/purge.js`. * Move configs to `node_modules/ssdeploy/configs`. ## 0.3.1 * Fix cleaned images count. ## 0.3 * Show the size of the image after build. ## 0.2.4 * Fix first deploy. * Fix deploy on local laptop. ## 0.2.3 * Clean up GitHub Actions workflow. ## 0.2.2 * Add `--verbose` support to `ssdeploy run` and `ssdeploy shell`. ## 0.2.1 * Do not open browser on `ssdeploy shell`. ## 0.2 * Rename project from `solid-state-deploy` to `ssdeploy`. * Add `shell` command. ## 0.1 * Initial release. ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright 2019 Andrey Sitnik Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Solid State Deploy Deploy simple websites with Google Cloud and Cloudflare. It is like Netlify with: * **Better performance.** Cloudflare has more features for fast websites than Netlify CDN. For instance, HTTP/3 and TLS 1.3 0-RTT. * **Flexibility.** You can have crontab jobs and simple scripts (without persistence storage). You will have powerful and well documented Nginx config to define custom headers and redirects. * **Lack of vendor lock-in.** We use industry standards like Docker and Nginx. You can change CI, CDN, or Docker cloud separately. * **Local tests.** You can run a server on your laptop to test redirects and scripts. You will have built-in HTTPS and deploy by `git push`. ssdeploy example We also have trade-offs. It is not free, but for a simple website, it will cost you cents per month. You need more steps to install it, but after you have the same simple workflow. Sponsored by Evil Martians ## Install 1. Create an account in [Google Cloud]. 2. Go to **IAM & Admin** → **Service Accounts**, click **Create Service Account** and fill form: * Service Account Name: `Github-actions` * Service Account Description: `Deploy from Github Actions` 3. Add **Cloud Run Admin**, **Storage Admin**, **Service Account User** roles. 4. Click **Create Key**, choose **JSON** → **Create**, download and keep file for a while. 5. Open **Container Registry** and enable the service. 6. Open **Cloud Run** and start the service. 7. Go to your Github page of your project at **Settings** → **Secrets**. 8. Add new secret `WEBSITE_URL` with URL to your website domain (like `example.com`). 9. Add new secret `GCLOUD_PROJECT` with Google Cloud project name like `test-255417`. You can find project ID by opening a project switcher at the top of [Google Cloud]. 10. Choose application name (like `examplecom`) and add `GCLOUD_APP` secret with this name. 11. Call `base64 key-partition-….json` (file from step 4) and add `GCLOUD_AUTH` secret with the base64 content of this file. 12. Install Solid State Deploy to your project. ```sh npm i ssdeploy ``` 13. Create Github Actions workflow by calling: ```sh npx ssdeploy init ``` 14. Your project should build HTML files by `npm build` and put them to `dist/`. 15. Push the project’s changes to Github Actions to start deploying. Open **Actions** tab on Github to check out the process. 16. Go to **Cloud Run** at [Google Cloud] and find your server. Open it by clicking on the name and find the URL like `examplecom-hjv54hv.a.run.app`. Check that the website is working. 17. Click on **Manage Custom Domains** → **Add mapping**. Select your app, **Verify a new domain**, and enter your domain name. Finish domain verification with Webmaster Central. 18. After verification open **Add mapping** dialog again, select your app, domain, and leave subdomain blank. You will get `A` and `AAAA` records. 19. Create a new [Cloudflare] account. Create a site with `A` and `AAAA` records from Cloud Run. 20. Enable **HTTP/3** and **0-RTT** in Cloudflare **Network** settings. 21. Find **Zone ID** at site overview and create **API token** with `cache cleaner` name and `Cache Purge`/`Edit` permission. 22. Use them in `CLOUDFLARE_ZONE` and `CLOUDFLARE_TOKEN` secrets at Github. 23. Go to Google Cloud Run, **Manage Custom Domains** → **Add mapping** to add `www` subdomain and add `CNAME` record to Cloudflare **DNS** settings. We recommend checking the final result [for blocking in Russia](https://isitblockedinrussia.com/) and recreate Cloudflare account to change IP addressed. Few extra steps will improve security: 1. Go to Cloudflare **SSL/TLS** settings and enable **Full** encryption mode. 2. Add `CAA` records to Cloudflare **DNS** settings: ```js CAA @ 0 "only allow specific hostname" digicert.com CAA @ 0 "only allow specific hostname" letsencrypt.org CAA www 0 "only allow specific hostname" digicert.com CAA www 0 "only allow specific hostname" letsencrypt.org ``` 3. Enable **DNSSEC** in **DNS** settings. 4. Enable **HTST** by creating `nginx.conf` in the root of your project with: ```cpp add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"; add_header X-Content-Type-Options "nosniff"; ``` [Google Cloud]: https://console.cloud.google.com/ [Cloudflare]: https://www.cloudflare.com/ ## Deploy Just push commits to `master`: ```sh git push origin master ``` You can switch deploy branch at `.Github/workflows/deploy.yml`. ## Run Server Locally To test the Docker image locally run: ```sh npm build npx ssdeploy run ``` ## Deploy Server Locally You can deploy a server from the laptop. It can be useful to debug. You need to install [Google Cloud SDK](https://cloud.google.com/sdk/install) and call: ```sh npx ssdeploy deploy ``` ## Custom Nginx config In custom Nginx config, you can define headers and redirects. Create `nginx.conf` in your project root. ```cpp if ($host ~ ^www\.(?.+)$) { return 301 https://$domain$request_uri; } location ~* "(\.css|\.png|\.svg|\.woff2)$" { add_header Cache-Control "public, max-age=31536000, immutable"; } ``` It will be included inside the `server` context. ## Custom Docker config Custom `Dockerfile` should be placed at your project root. It can be used to define crontab jobs: ```sh FROM nginx:alpine RUN rm -R /etc/nginx/conf.d COPY ./dist/ /var/www/ COPY ./node_modules/ssdeploy/configs/nginx.conf /etc/nginx/nginx.template COPY ./nginx.conf /etc/nginx/server.conf RUN echo "#\!/bin/sh\necho 1" > /etc/periodic/hourly/example RUN chmod a+x /etc/periodic/hourly/example CMD crond && envsubst \$PORT < /etc/nginx/nginx.template > /etc/nginx/nginx.conf && nginx ``` ================================================ FILE: bin.js ================================================ #!/usr/bin/env node import dotenv from 'dotenv' import pico from 'picocolors' import deploy, { stopPreview } from './lib/deploy.js' import showVersion from './lib/show-version.js' import showHelp from './lib/show-help.js' import runImage from './lib/run-image.js' import changed from './lib/changed.js' import purge from './lib/purge.js' import init from './lib/init.js' import sign from './lib/sign.js' dotenv.config() function getPullRequestId() { let pr = process.argv[3] if (!pr) { process.stderr.write(pico.red('Missed pull request ID') + '\n') process.exit(1) } return pr } async function run() { let command = process.argv[2] if (command === '--version') { await showVersion() } else if (!command || command === 'help' || command === '--help') { showHelp() } else if (command === 'init') { await init() } else if (command === 'shell') { await runImage('/bin/sh') } else if (command === 'run') { await runImage() } else if (command === 'purge') { await purge() } else if (command === 'changed') { await changed() } else if (command === 'deploy') { await deploy() } else if (command === 'preview') { await deploy(getPullRequestId()) } else if (command === 'close') { await stopPreview(getPullRequestId()) } else if (command === 'sign') { let file = process.argv[3] if (!file) { process.stderr.write(pico.red('Missed file to sign') + '\n') process.exit(1) } await sign(file) } else { process.stderr.write(pico.red(`Unknown command ${command}`) + '\n\n') showHelp() process.exit(1) } } run().catch(e => { if (!e.own) process.stderr.write(pico.red(e.stack) + '\n') process.exit(1) }) ================================================ FILE: configs/.dockerignore ================================================ node_modules/ !node_modules/ssdeploy/configs/ .github/ .cache/ .git/ src/ .gitignore .editorconfig README.md LICENSE package.json yarn.lock pnpm-lock.yaml ================================================ FILE: configs/Dockerfile ================================================ FROM nginx:alpine RUN rm -R /etc/nginx/conf.d COPY ./nginx-template.conf /etc/nginx/nginx.template COPY ./nginx.conf /etc/nginx/server.conf COPY ./dist/ /var/www/ CMD envsubst \$PORT < /etc/nginx/nginx.template > /etc/nginx/nginx.conf && nginx ================================================ FILE: configs/close.yml ================================================ name: Clean Preview on: pull_request: types: [ closed ] jobs: close: runs-on: ubuntu-latest steps: - name: Clean from GitHub uses: bobheadxi/deployments@v1 with: step: delete-env token: ${{ secrets.GITHUB_TOKEN }} env: preview-${{ github.event.number }} - name: Checkout the repository uses: actions/checkout@v3 - name: Install Node.js uses: actions/setup-node@v3 with: node-version: 18 cache: npm - name: Install dependencies run: npm install --production --frozen-lockfile - name: Auth Google Cloud uses: google-github-actions/auth@v0 with: credentials_json: ${{ secrets.GCLOUD_AUTH }} - name: Install Google Cloud uses: google-github-actions/setup-gcloud@v0 - name: Clean from Google Cloud run: ./node_modules/.bin/ssdeploy close $PR --verbose env: PR: ${{ github.event.number }} GCLOUD_APP: ${{ secrets.GCLOUD_APP }} GCLOUD_PROJECT: ${{ secrets.GCLOUD_PROJECT }} CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }} CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} ================================================ FILE: configs/deploy.yml ================================================ name: Deploy on: push: branches: - main env: FORCE_COLOR: 2 jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout the repository uses: actions/checkout@v3 - name: Install Node.js uses: actions/setup-node@v3 with: node-version: 18 cache: npm - name: Install dependencies run: npm install --production --frozen-lockfile - name: Build static files run: npm build - name: Check files changes id: hash run: ./node_modules/.bin/ssdeploy changed env: WEBSITE_URL: ${{ secrets.WEBSITE_URL }} - name: Auth Google Cloud uses: google-github-actions/auth@v0 with: credentials_json: ${{ secrets.GCLOUD_AUTH }} - name: Install Google Cloud if: "!steps.hash.outputs.noChanges" uses: google-github-actions/setup-gcloud@v0 - name: Deploy files if: "!steps.hash.outputs.noChanges" run: ./node_modules/.bin/ssdeploy deploy --verbose env: GCLOUD_APP: ${{ secrets.GCLOUD_APP }} GCLOUD_PROJECT: ${{ secrets.GCLOUD_PROJECT }} CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }} CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} ================================================ FILE: configs/nginx-template.conf ================================================ worker_processes 1; pid /run/nginx.pid; daemon off; events { worker_connections 1024; } http { access_log off; error_log stderr error; server_tokens off; include mime.types; types { application/manifest+json webmanifest; } default_type application/octet-stream; charset_types application/javascript text/css application/manifest+json image/svg+xml; sendfile on; server { listen $PORT; root /var/www; charset UTF-8; gzip_static on; include /etc/nginx/server.conf; } } ================================================ FILE: configs/preview.yml ================================================ name: Preview on: pull_request: env: FORCE_COLOR: 2 jobs: preview: runs-on: ubuntu-latest if: github.ref != 'refs/heads/main' steps: - name: Notify about new deployment uses: bobheadxi/deployments@v1 id: deployment with: step: start token: ${{ secrets.GITHUB_TOKEN }} ref: ${{ github.head_ref }} env: preview-${{ github.event.number }} - name: Checkout the repository uses: actions/checkout@v3 - name: Install Node.js uses: actions/setup-node@v3 with: node-version: 18 cache: npm - name: Install dependencies run: npm install --production --frozen-lockfile - name: Build static files run: npm build - name: Check files changes id: hash run: ./node_modules/.bin/ssdeploy changed env: WEBSITE_URL: ${{ secrets.WEBSITE_URL }} - name: Auth Google Cloud uses: google-github-actions/auth@v0 with: credentials_json: ${{ secrets.GCLOUD_AUTH }} - name: Install Google Cloud uses: google-github-actions/setup-gcloud@v0 - name: Deploy files id: deploy run: ./node_modules/.bin/ssdeploy preview $PR --verbose env: PR: ${{ github.event.number }} GCLOUD_APP: ${{ secrets.GCLOUD_APP }} GCLOUD_PROJECT: ${{ secrets.GCLOUD_PROJECT }} CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }} CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} - name: Update deployment status uses: bobheadxi/deployments@v1 if: always() with: step: finish token: ${{ secrets.GITHUB_TOKEN }} status: ${{ job.status }} env: ${{ steps.deployment.outputs.env }} env_url: ${{ steps.deploy.outputs.url }} deployment_id: ${{ steps.deployment.outputs.deployment_id }} ================================================ FILE: lib/build.js ================================================ import { writeFile, copyFile, unlink } from 'fs/promises' import { basename, join } from 'path' import { spawn, exec } from 'child_process' import { existsSync } from 'fs' import bytes from 'bytes' import pico from 'picocolors' import debug, { debugCmd } from './debug.js' import { findPackageDir } from './find-package-up.js' import detectDocker from './detect-docker.js' import showSpinner from './show-spinner.js' import { CONFIGS } from './dirs.js' function getSize(bin, name, env) { return new Promise(resolve => { exec( `${bin} image inspect ${name}:latest --format='{{.Size}}'`, { env }, (error, stdout) => { if (error) { resolve() } else { resolve(parseInt(stdout)) } } ) }) } export default async function build(name, env = process.env, forceDocker) { let root = await findPackageDir() if (!name) name = basename(root) let bin = forceDocker ? 'docker' : await detectDocker() let customDocker = join(root, 'Dockerfile') let localDocker = join(CONFIGS, 'Dockerfile') let customNginxTemplate = join(root, 'nginx-template.conf') let localNginxTemplate = join(CONFIGS, 'nginx-template.conf') let customIgnore = join(root, '.dockerignore') let localIgnore = join(CONFIGS, '.dockerignore') let nginxPath = join(root, 'nginx.conf') let dockerfile = localDocker if (existsSync(customDocker)) dockerfile = customDocker let args = ['build', '-f', dockerfile, '-t', name, '.'] debugCmd(bin + ' ' + args.join(' ')) let text = 'Building Docker image' if (bin === 'podman') text = 'Building Podman image' let spinner = showSpinner(text) let temp = [] try { if (!existsSync(customIgnore)) { await copyFile(localIgnore, customIgnore) temp.push(customIgnore) } if (!existsSync(nginxPath)) { await writeFile(nginxPath, '\n') temp.push(nginxPath) } if (!existsSync(customNginxTemplate)) { await copyFile(localNginxTemplate, customNginxTemplate) temp.push(customNginxTemplate) } await new Promise((resolve, reject) => { let docker = spawn(bin, args, { env }) let steps = 0 docker.stdout.on('data', data => { if (!process.env.CI && !process.env.GITHUB_ACTIONS) { if (data.toString().startsWith('STEP ')) { steps += 1 process.stdout.write(`${text}: step ${steps}\n`) } } debug(data) }) let output = '' docker.stderr.on('data', data => { output += data.toString() }) docker.on('exit', code => { if (code === 0) { resolve() } else { spinner.fail() process.stderr.write(pico.red(output)) let err = new Error('Docker error ' + code) err.own = true reject(err) } }) }) } finally { await Promise.all(temp.map(i => unlink(i))) } let size = await getSize(bin, name, env) if (size) { spinner.succeed(`Built ${bytes(size, { unitSeparator: ' ' })} image`) } return name } ================================================ FILE: lib/call-cloudflare.js ================================================ import { request } from 'https' export default async function callCloudflare (command, opts) { await new Promise((resolve, reject) => { let req = request( { method: 'POST', hostname: 'api.cloudflare.com', path: `/client/v4/zones/${process.env.CLOUDFLARE_ZONE}/${command}`, headers: { 'Authorization': `Bearer ${process.env.CLOUDFLARE_TOKEN}`, 'Content-Type': 'application/json' } }, res => { let data = '' res.on('data', chunk => { data += chunk }) res.on('end', () => { let answer try { answer = JSON.parse(data) } catch { console.error(data) process.exit(1) } if (answer.success) { resolve() } else { reject(new Error(answer.errors[0].message)) } }) } ) if (opts) req.write(JSON.stringify(opts)) req.on('error', reject) req.end() }) } ================================================ FILE: lib/changed.js ================================================ import { existsSync, createReadStream } from 'fs' import { writeFile } from 'fs/promises' import folderHash from 'folder-hash' import { join } from 'path' import { get } from 'https' import hasha from 'hasha' import pico from 'picocolors' import showError, { showWarning } from './show-error.js' import showSpinner, { wrap } from './show-spinner.js' import { findPackageDir } from './find-package-up.js' async function fileHash(file) { if (!existsSync(file)) return '' return hasha.fromStream(createReadStream(file)) } async function loadPrev(url) { if (url.startsWith('http://')) { throw showError('`ssdeploy changed` supports only https:// websites') } if (!url.startsWith('https://')) { url = 'https://' + url } try { new URL(url) } catch (e) { if (e.code === 'ERR_INVALID_URL') { throw showError( 'Ivalid URL `' + url + '` in `WEBSITE_URL` environment variables' ) } else { throw e } } if (!url.endsWith('/')) url += '/' let hashUrl = url + 'ssdeploy-hash.txt' let spinner = showSpinner('Loading hash from previous deploy') return new Promise(resolve => { function error(e) { spinner.fail() if (typeof e === 'string') { showWarning(e) } else { showWarning(e.message) } resolve('') } let req = get(hashUrl, res => { if (res.statusCode === 404) { error( '`' + hashUrl + '` file was not found.\n' + 'It is OK for first deploy.' ) return } else if (res.statusCode !== 200) { error( 'Your website return ' + res.statusCode + ' code ' + 'on `' + hashUrl + '` request' ) } let data = '' res.on('data', chunk => { data += chunk.toString() }) res.on('end', () => { if (data.endsWith('=')) { spinner.succeed() resolve(data) } else { error('Wrong data found at `' + hashUrl + '`') } }) }) req.on('error', error) req.end() }) } async function calcCurrent() { let root = await findPackageDir() let dist = join(root, 'dist') let ignoreFile = join(root, '.dockerignore') let dockerFile = join(root, 'Dockerfile') let nginxFile = join(root, 'nginx.conf') let workflowFile = join(root, '.github', 'workflows', 'deploy.yml') let [{ hash }, workflow, ignore, docker, nginx] = await Promise.all([ folderHash.hashElement(dist), fileHash(workflowFile), fileHash(ignoreFile), fileHash(dockerFile), fileHash(nginxFile) ]) return [dist, hasha(hash + workflow + ignore + docker + nginx) + '='] } export default async function changed() { if (!process.env.WEBSITE_URL) { showWarning( 'Set `WEBSITE_URL` environment variables at your CI ', 'to deploy websites only when files will be changed' ) return } let [prev, [dist, current]] = await Promise.all([ loadPrev(process.env.WEBSITE_URL.trim()), calcCurrent() ]) await wrap('Writing ssdeploy-hash.txt', async () => { await writeFile(join(dist, 'ssdeploy-hash.txt'), current) }) if (prev === current) { process.stderr.write( pico.yellow('Files were not changed from latest deploy. Stop deploy.\n') ) await writeFile(process.env.GITHUB_OUTPUT, 'noChanges=1\n') } else { if (prev !== '') { process.stderr.write('Files was changed. ') } process.stderr.write('Continue deploy.\n') } } ================================================ FILE: lib/debug.js ================================================ import pico from 'picocolors' export let isDebug = process.argv.includes('--verbose') export function debugCmd(cmd) { debug(pico.gray('$ ' + cmd) + '\n') } export default function debug(text) { if (isDebug) { process.stderr.write(text) } } ================================================ FILE: lib/deploy.js ================================================ import { spawn, execSync } from 'child_process' import { writeFile } from 'fs/promises' import pico from 'picocolors' import debug, { isDebug, debugCmd } from './debug.js' import { wrap } from './show-spinner.js' import showError from './show-error.js' import build from './build.js' import purge from './purge.js' let safeEnv = {} for (let i in process.env) { if (!/^(GCLOUD_|CLOUDFLARE_)/.test(i)) safeEnv[i] = process.env[i] } async function exec(command, opts) { return new Promise((resolve, reject) => { debugCmd(command) let cmd = spawn(command, { ...opts, env: safeEnv, shell: '/bin/bash' }) let stdout = '' let stderr = '' cmd.stdout.on('data', data => { let out = data.toString() stdout += out debug(out) }) cmd.stderr.on('data', data => { stderr += data.toString() debug(pico.yellow(data)) }) cmd.on('exit', code => { if (code === 0) { resolve(stdout.trim()) } else if (isDebug) { reject(showError('Exit code ' + code)) } else { reject(showError(stderr)) } }) }) } async function push(image) { await wrap('Pushing image to Google Cloud Registry', async () => { await exec('gcloud auth configure-docker --quiet') await exec(`docker push ${image}`) }) } async function setPreviewUrl(project, app, region = 'us-east1', preview) { let base = execSync( `gcloud run services describe ${app} ` + `--project ${project} --region ${region} --format 'value(status.url)'` ) .toString() .trim() let url = base.replace('https://', `https://preview-${preview}---`) await writeFile(process.env.GITHUB_OUTPUT, `url=${url}\n`) } async function run(image, project, app, region, preview) { await wrap('Starting image on Google Cloud Run', async spinner => { await exec( `gcloud run deploy ${app} --image ${image} ` + `--project ${project} --region=${region} ` + '--platform managed --allow-unauthenticated' + (preview ? ` --tag preview-${preview} --no-traffic` : '') ) spinner.succeed(`Image was deployed at ${region} server`) }) } async function cleanOldImages(image, tag) { await wrap('Cleaning registry from old images', async spinner => { let removed = 0 let filter = '-tags:*' if (tag) filter = `tags:${tag}` let out = await exec( 'gcloud container images list-tags ' + `${image} --filter='${filter}' --format='get(digest)'` ) if (out !== '') { await Promise.all( out.split('\n').map(i => { removed += 1 return exec( `gcloud container images delete '${image}@${i}' --quiet` + (tag ? ' --force-delete-tags' : '') ) }) ) } spinner.succeed(`Removed ${removed} images`) }) } async function migrateTraffic(project, app, region) { await wrap('Moving traffic to new revision', async () => { await exec( 'gcloud run services update-traffic ' + `${app} --project ${project} --region=${region} --to-latest` ) }) } export default async function deploy(preview) { if (!process.env.GCLOUD_PROJECT || !process.env.GCLOUD_APP) { throw showError( 'Set `GCLOUD_PROJECT` and `GCLOUD_APP` environment variables at your CI' ) } let project = process.env.GCLOUD_PROJECT.trim() let app = process.env.GCLOUD_APP.trim() let region = process.env.GCLOUD_REGION || 'us-east1' let image = `gcr.io/${project}/${app}` let taggedImage = preview ? `${image}:preview-${preview}` : image await build(taggedImage, safeEnv, true, preview) await push(taggedImage) await run(taggedImage, project, app, region, preview) if (preview) { await setPreviewUrl(project, app, region, preview) } else { await migrateTraffic(project, app, region) await purge() } await cleanOldImages(image) } export async function stopPreview(preview) { let project = process.env.GCLOUD_PROJECT.trim() let app = process.env.GCLOUD_APP.trim() let image = `gcr.io/${project}/${app}` let region = process.env.GCLOUD_REGION || 'us-east1' await exec( `gcloud run services update-traffic ${app} ` + `--region ${region} --project ${project} ` + `--remove-tags preview-${preview} --async` ) await cleanOldImages(image, `preview-${preview}`) } ================================================ FILE: lib/detect-docker.js ================================================ import { promisify } from 'util' import child from 'child_process' let exec = promisify(child.exec) let runner export default async function detectDocker () { if (!runner) { try { await exec('podman --version') runner = 'podman' } catch (e) { runner = 'docker' } } return runner } ================================================ FILE: lib/dirs.js ================================================ import { dirname, join } from 'path' let self = new URL(import.meta.url).pathname export const ROOT = join(dirname(self), '..') export const CONFIGS = join(ROOT, 'configs') ================================================ FILE: lib/find-package-up.js ================================================ import { resolve, parse, dirname } from 'path' import { existsSync } from 'fs' import showError from './show-error.js' async function findUp(name, cwd = '') { let directory = resolve(cwd) let { root } = parse(directory) while (true) { let foundPath = await resolve(directory, name) if (existsSync(foundPath)) { return foundPath } if (directory === root) { return undefined } directory = dirname(directory) } } export async function findPackageUp(cwd) { let path = await findUp('package.json', cwd) if (!path) throw showError('Can’t find package.json') return path } export async function findPackageDir(cwd) { return dirname(await findPackageUp(cwd)) } ================================================ FILE: lib/init.js ================================================ import { readFile, mkdir, writeFile } from 'fs/promises' import { dirname, join } from 'path' import { existsSync } from 'fs' import { findPackageDir } from './find-package-up.js' import { CONFIGS } from './dirs.js' async function copyTemplate(manager, name, to) { await mkdir(dirname(to), { recursive: true }) let template = await readFile(join(CONFIGS, name)) let config = template.toString() if (manager === 'yarn') { config = config .replace('cache: npm', 'cache: yarn') .replace(' npm ', ' yarn ') } else if (manager === 'pnpm') { config = config .replace('cache: npm', 'cache: pnpm') .replace(' npm ', ' pnpm ') } await writeFile(join(to, name), config) } export default async function init() { let root = await findPackageDir() let workflows = join(root, '.github', 'workflows') let manager = 'npm' if (existsSync(join(root, 'yarn.lock'))) { manager = 'yarn' } else if (existsSync(join(root, 'pnpm-lock.yaml'))) { manager = 'pnpm' } await Promise.all([ copyTemplate(manager, workflows, 'deploy.yml'), copyTemplate(manager, workflows, 'preview.yml'), copyTemplate(manager, workflows, 'close.yml') ]) } ================================================ FILE: lib/purge.js ================================================ import callCloudflare from './call-cloudflare.js' import { showWarning } from './show-error.js' import { wrap } from './show-spinner.js' export default async function purge () { if (!process.env.CLOUDFLARE_ZONE || !process.env.CLOUDFLARE_TOKEN) { showWarning( 'Get zone ID and API token in CLoudflare dashboard', 'and set `CLOUDFLARE_TOKEN` and `CLOUDFLARE_ZONE`', 'environment variables at your CI' ) } else { await wrap('Cleaning CDN cache', async spinner => { await callCloudflare('purge_cache', { purge_everything: true }) spinner.succeed('CDN cache was cleaned') }) } } ================================================ FILE: lib/run-image.js ================================================ import { existsSync } from 'fs' import { spawn } from 'child_process' import { join } from 'path' import open from 'open' import pico from 'picocolors' import { findPackageDir } from './find-package-up.js' import detectDocker from './detect-docker.js' import { debugCmd } from './debug.js' import build from './build.js' let y = pico.yellow export default async function runImage(script) { let bin = await detectDocker() let name = await build() let root = await findPackageDir() let args = ['run'] if (existsSync(join(root, 'dist'))) { args = args.concat(['-v', './dist/:/var/www/']) } args = args.concat([ '--privileged', '--rm', '-p', '8000:80', '-e', 'PORT=80', '-it', name ]) if (script) args.push(script) debugCmd(bin + ' ' + args.join(' ')) spawn(bin, args, { stdio: 'inherit' }) if (!script) { let ctrlC = 'Ctrl+C' if (process.platform === 'darwin') ctrlC = 'Cmd + .' process.stderr.write( ` Website is available to test at ${y('http://localhost:8000/')}\n` + ` Press ${y(ctrlC)} to stop the server\n` ) open('http://localhost:8000/') } } ================================================ FILE: lib/show-error.js ================================================ import pico from 'picocolors' export function showWarning(...lines) { lines = lines.map(line => { return line.replace(/`[^`]+`/g, i => pico.yellow(i.slice(1, -1))) }) process.stderr.write(pico.red(lines.join('\n')) + '\n') } export default function showError(...lines) { showWarning(...lines) let err = new Error('show error') err.own = true return err } ================================================ FILE: lib/show-help.js ================================================ import pico from 'picocolors' let b = pico.bold let y = pico.yellow let g = pico.green function print(...lines) { process.stdout.write(lines.join('\n') + '\n') } export default function showHelp() { print( b('Usage: ') + 'npx ssdeploy ' + g('COMMAND') + y(' [OPTION]'), 'Deploy simple websites with Google Cloud and Cloudflare', '', b('Commands:'), ' ' + g('init') + ' Create .github/workflow/deploy.yml', ' ' + g('deploy') + ' Deploy website to the cloud', ' ' + g('preview') + ' ' + y('PR') + ' Deploy preview', ' ' + g('close') + ' ' + y('PR') + ' Clean files from preview', ' ' + g('run') + ' Run Docker image locally', ' ' + g('shell') + ' Run shell inside Docker image', ' ' + g('purge') + ' Clean CDN cache', ' ' + g('changed') + ' Check changes between dist/ and website', ' ' + g('sign') + ' ' + y('FILE') + ' Sign security.txt', '', b('Arguments:'), ' ' + y('--verbose') + ' Show debug information', '', b('Examples:'), ' npx ssdeploy deploy', ' npx ssdeploy run', ' npx ssdeploy sign ./src/well-known/security.txt' ) } ================================================ FILE: lib/show-spinner.js ================================================ import pico from 'picocolors' export default function showSpinner(text) { process.stdout.write(pico.gray('- ') + text + '\n') let finished = false return { succeed(newText = text) { if (!finished) { finished = true process.stdout.write(pico.green('✔ ') + newText + '\n') } }, fail(newText = text) { if (!finished) { finished = true process.stdout.write(pico.red('✖ ') + newText + '\n') } } } } export async function wrap(text, cb) { let spinner = showSpinner(text) try { let result = await cb(spinner) spinner.succeed() return result } catch (e) { spinner.fail() throw e } } ================================================ FILE: lib/show-version.js ================================================ import { readFile } from 'fs/promises' import { join } from 'path' import pico from 'picocolors' import { ROOT } from './dirs.js' export default async function showVersion() { let packagePath = join(ROOT, 'package.json') let packageJson = await readFile(packagePath) let { version } = JSON.parse(packageJson) process.stdout.write(`ssdeploy ${pico.bold(version)}\n`) } ================================================ FILE: lib/sign.js ================================================ import { writeFile, unlink, rename, readFile } from 'fs/promises' import { promisify } from 'util' import child from 'child_process' let exec = promisify(child.exec) export default async function sign(path) { let content = await readFile(path) let cleared = content .toString() .replace(/-----BEGIN PGP SIGNED MESSAGE-----\nHash:[^\n]+\n\n/, '') .replace(/-----BEGIN PGP SIGNATURE-----\n\n[\W\w]+$/m, '') await writeFile(path, cleared) await exec(`gpg --clearsign ${path}`) await unlink(path) await rename(path + '.asc', path) } ================================================ FILE: package.json ================================================ { "name": "ssdeploy", "version": "0.9.3", "description": "Netlify replacement to deploy simple websites with better flexibility and speed and without vendor lock-in", "keywords": [ "deploy", "google cloud", "cloudflare", "netlify" ], "bin": "./bin.js", "type": "module", "engines": { "node": ">=14.0.0" }, "scripts": { "test": "eslint ." }, "dependencies": { "bytes": "^3.1.2", "dotenv": "^16.0.3", "folder-hash": "^4.0.4", "hasha": "^5.2.2", "open": "^8.4.2", "picocolors": "^1.0.0" }, "author": "Andrey Sitnik ", "license": "MIT", "repository": "ai/ssdeploy", "devDependencies": { "@logux/eslint-config": "^49.0.0", "clean-publish": "^4.1.1", "eslint": "^8.36.0", "eslint-config-standard": "^17.0.0", "eslint-plugin-import": "^2.27.5", "eslint-plugin-n": "^15.6.1", "eslint-plugin-prefer-let": "^3.0.1", "eslint-plugin-promise": "^6.1.1" }, "eslintConfig": { "extends": "@logux/eslint-config", "rules": { "no-console": "off" } }, "eslintIgnore": [ "lib/dirs.js" ], "prettier": { "arrowParens": "avoid", "jsxSingleQuote": false, "quoteProps": "consistent", "semi": false, "singleQuote": true, "trailingComma": "none" }, "clean-publish": { "cleanDocs": true } } ================================================ FILE: purge.js ================================================ #!/usr/bin/env node import callCloudflare from './lib/call-cloudflare.js' callCloudflare('purge_cache', { purge_everything: true }).catch(e => { process.stderr.write(e.stack + '\n') process.exit(1) })