diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 0000000..f8a596f --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,35 @@ +name: Promote Release + +on: + # Triggered by the dist server + workflow_dispatch: + inputs: + path: + description: 'path to promote' + required: true + recursive: + description: 'is the path a directory' + type: boolean + required: true + +jobs: + promote-release: + name: Promote Release + runs-on: ubuntu-latest + + steps: + - name: Git Checkout + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'npm' + + - name: Promote Files + env: + CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }} + CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }} + run: | + node scripts/promote-release.mjs ${{ inputs.path }} ${{ inputs.recursive == true && '--recursive' || '' }} diff --git a/package.json b/package.json index 858a441..b1c92d6 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "start": "wrangler dev --remote", - "format": "prettier --check --write \"**/*.{ts,js,json,md}\"", - "prettier": "prettier --check \"**/*.{ts,js,json,md}\"", + "format": "prettier --check --write \"**/*.{ts,js,mjs,json,md}\"", + "prettier": "prettier --check \"**/*.{ts,js,mjs,json,md}\"", "lint": "eslint ./src", "test": "npm run test:unit && npm run test:e2e", "test:unit": "node --test --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=spec --test-reporter-destination=stdout --import=tsx ./tests/unit/index.test.ts", diff --git a/scripts/constants.mjs b/scripts/constants.mjs new file mode 100644 index 0000000..f1a7be9 --- /dev/null +++ b/scripts/constants.mjs @@ -0,0 +1,11 @@ +'use strict'; + +export const ENDPOINT = + process.env.ENDPOINT ?? + 'https://07be8d2fbc940503ca1be344714cb0d1.r2.cloudflarestorage.com'; + +export const PROD_BUCKET = process.env.PROD_BUCKET ?? 'dist-prod'; + +export const STAGING_BUCKET = process.env.STAGING_BUCKET ?? 'dist-staging'; + +export const R2_RETRY_COUNT = 3; diff --git a/scripts/promote-release.mjs b/scripts/promote-release.mjs new file mode 100755 index 0000000..2bcbc66 --- /dev/null +++ b/scripts/promote-release.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node + +/** + * Usage: `promote-release [--recursive]` + * ex/ `promote-release nodejs/release/v20.0.0/ --recursive` + */ + +import { + S3Client, + ListObjectsV2Command, + CopyObjectCommand, +} from '@aws-sdk/client-s3'; +import { + ENDPOINT, + PROD_BUCKET, + STAGING_BUCKET, + R2_RETRY_COUNT, +} from './constants.mjs'; + +if (process.argv.length !== 3 && process.argv.length !== 4) { + console.error( + `usage: promote-release [--recursive]` + ); + process.exit(1); +} + +if (!process.env.CF_ACCESS_KEY_ID) { + console.error('CF_ACCESS_KEY_ID missing'); + process.exit(1); +} + +if (!process.env.CF_SECRET_ACCESS_KEY) { + console.error('CF_SECRET_ACCESS_KEY missing'); + process.exit(1); +} + +const client = new S3Client({ + endpoint: ENDPOINT, + region: 'auto', + credentials: { + accessKeyId: process.env.CF_ACCESS_KEY_ID, + secretAccessKey: process.env.CF_SECRET_ACCESS_KEY, + }, +}); + +const path = process.argv[2]; +const recursive = + process.argv.length === 4 && process.argv[3] === '--recursive'; + +if (recursive) { + const files = await getFilesToPromote(path); + + for (const file of files) { + await promoteFile(file); + } +} else { + await promoteFile(file); +} + +/** + * @param {string} path + * @returns {string[]} + */ +async function getFilesToPromote(path) { + let paths = []; + + let truncated = true; + let continuationToken; + while (truncated) { + const data = await retryWrapper(async () => { + return await client.send( + new ListObjectsV2Command({ + Bucket: STAGING_BUCKET, + Delimiter: '/', + Prefix: path, + ContinuationToken: continuationToken, + }) + ); + }); + + if (data.CommonPrefixes) { + for (const directory of data.CommonPrefixes) { + paths.push(...(await getFilesToPromote(directory.Prefix))); + } + } + + if (data.Contents) { + for (const object of data.Contents) { + paths.push(object.Key); + } + } + + truncated = data.IsTruncated ?? false; + continuationToken = data.NextContinuationToken; + } + + return paths; +} + +/** + * @param {string} file + */ +async function promoteFile(file) { + console.log(`Promoting ${file}`); + + await retryWrapper(async () => { + return await client.send( + new CopyObjectCommand({ + Bucket: PROD_BUCKET, + CopySource: `${STAGING_BUCKET}/${file}`, + Key: file, + }) + ); + }, R2_RETRY_COUNT); +} + +/** + * @param {() => Promise} request + * @returns {Promise} + */ +async function retryWrapper(request, retryLimit) { + let r2Error; + + for (let i = 0; i < R2_RETRY_COUNT; i++) { + try { + const result = await request(); + return result; + } catch (err) { + r2Error = err; + process.emitWarning(`error when contacting r2: ${err}`); + } + } + + throw r2Error; +}