From a1867d2b747e614e5155d7046c4f7a62eb9e5dc3 Mon Sep 17 00:00:00 2001 From: Matej Vobornik Date: Wed, 11 Sep 2024 16:42:41 +0200 Subject: [PATCH 1/2] Load deploy hash based on current commit hash --- actions/cursor-deploy/dist/main/index.js | 47 +++++++++++------- actions/cursor-deploy/index.test.ts | 62 +++++++++++------------- actions/cursor-deploy/index.ts | 53 ++++++++++---------- actions/utils.test.ts | 32 ++++-------- actions/utils.ts | 40 +++++++++------ 5 files changed, 117 insertions(+), 117 deletions(-) diff --git a/actions/cursor-deploy/dist/main/index.js b/actions/cursor-deploy/dist/main/index.js index 1eea9c93..126f87e8 100644 --- a/actions/cursor-deploy/dist/main/index.js +++ b/actions/cursor-deploy/dist/main/index.js @@ -24280,6 +24280,12 @@ async function copyFileToS3({ }) { await (0, import_exec.exec)("aws s3 cp", [path, `s3://${bucket}/${key}`]); } +async function readFileFromS3({ key, bucket }) { + await (0, import_exec.exec)("aws s3 cp", [`s3://${bucket}/${key}`, "tmp"]); + const data = await execReadOutput("cat", ["tmp"]); + await (0, import_exec.exec)("rm", ["tmp"]); + return data; +} async function removeFileFromS3({ key, bucket }) { await (0, import_exec.exec)("aws s3 rm", [`s3://${bucket}/${key}`]); } @@ -24298,11 +24304,8 @@ async function runAction(action) { async function isHeadAncestor(commitHash) { return execIsSuccessful("git merge-base", [`--is-ancestor`, commitHash, `HEAD`]); } -async function getTreeHashForCommitHash(commit) { - return execReadOutput("git rev-parse", [`${commit}:`]); -} -async function getCurrentRepoTreeHash() { - return getTreeHashForCommitHash("HEAD"); +async function getCommitHashFromRef(ref) { + return execReadOutput(`git rev-parse`, [ref]); } // cursor-deploy/index.ts @@ -24318,7 +24321,7 @@ runAction(async () => { rollbackCommitHash, ref: ((_b = (_a2 = github.context.payload) == null ? void 0 : _a2.pull_request) == null ? void 0 : _b.head.ref) ?? github.context.ref }); - core2.setOutput("tree_hash", output.treeHash); + core2.setOutput("deploy_hash", output.deployHash); core2.setOutput("branch_label", output.branchLabel); }); async function cursorDeploy({ @@ -24329,7 +24332,8 @@ async function cursorDeploy({ }) { const deployMode = getDeployMode(deployModeInput); const branchLabel = branchNameToHostnameLabel(ref); - const treeHash = await getDeploymentHash(deployMode, rollbackCommitHash); + const commitHash = await getDeployCommitHash(deployMode, rollbackCommitHash); + const deployHash = await getDeploymentHashFromCommitHash(bucket, commitHash); const rollbackKey = `rollbacks/${branchLabel}`; const deployKey = `deploys/${branchLabel}`; if (deployMode === "default" || deployMode === "unblock") { @@ -24341,9 +24345,9 @@ async function cursorDeploy({ throw new Error(`${branchLabel} does not have an active rollback, you can't unblock.`); } } - await writeLineToFile({ text: treeHash, path: branchLabel }); + await writeLineToFile({ text: deployHash, path: branchLabel }); await copyFileToS3({ path: branchLabel, bucket, key: deployKey }); - core2.info(`Tree hash ${treeHash} is now the active deployment for ${branchLabel}.`); + core2.info(`Deploy hash ${deployHash} is now the active deployment for ${branchLabel}.`); if (deployMode === "rollback") { await copyFileToS3({ path: branchLabel, bucket, key: rollbackKey }); core2.info(`${branchLabel} marked as rolled back, automatic deploys paused.`); @@ -24352,7 +24356,7 @@ async function cursorDeploy({ await removeFileFromS3({ bucket, key: rollbackKey }); core2.info(`${branchLabel} has automatic deploys resumed.`); } - return { treeHash, branchLabel }; + return { deployHash, branchLabel }; } function getDeployMode(deployMode) { function assertDeployMode(value) { @@ -24363,19 +24367,26 @@ function getDeployMode(deployMode) { assertDeployMode(deployMode); return deployMode; } -async function getDeploymentHash(deployMode, rollbackCommitHash) { +async function getDeployCommitHash(deployMode, rollbackCommitHash) { if (deployMode === "rollback") { if (!!rollbackCommitHash && !await isHeadAncestor(rollbackCommitHash)) { throw new Error("The selected rollback commit is not present on the branch"); } - const commit = rollbackCommitHash || "HEAD^"; - const treeHash2 = await getTreeHashForCommitHash(commit); - core2.info(`Rolling back to tree hash ${treeHash2} (commit ${commit})`); - return treeHash2; + return getCommitHashFromRef(rollbackCommitHash || "HEAD^"); + } + return getCommitHashFromRef("HEAD"); +} +async function getDeploymentHashFromCommitHash(bucket, commitHash) { + const commitKey = `deploys/commits/${commitHash}`; + const deployHash = await readFileFromS3({ + bucket, + key: commitKey + }); + if (!deployHash) { + throw Error(`Deploy hash for commit ${commitHash} not found.`); } - const treeHash = await getCurrentRepoTreeHash(); - core2.info(`Using current root tree hash ${treeHash}`); - return treeHash; + core2.info(`Using deploy hash ${deployHash} (commit ${commitHash})`); + return deployHash; } function branchNameToHostnameLabel(ref) { var _a2, _b; diff --git a/actions/cursor-deploy/index.test.ts b/actions/cursor-deploy/index.test.ts index eb2f0a57..97e2d97e 100644 --- a/actions/cursor-deploy/index.test.ts +++ b/actions/cursor-deploy/index.test.ts @@ -19,8 +19,8 @@ describe(`Cursor Deploy Action`, () => { And the tree hash used is the current repo tree hash `, async () => { - const treeHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' - mockedUtils.getCurrentRepoTreeHash.mockResolvedValue(treeHash) + const deployHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' + mockedUtils.readFileFromS3.mockResolvedValue(deployHash) mockedUtils.fileExistsInS3.mockResolvedValue(false) const output = await cursorDeploy({ @@ -33,13 +33,13 @@ describe(`Cursor Deploy Action`, () => { expectRollbackFileChecked('my-bucket', 'rollbacks/main') expectCursorFileUpdated({ - treeHash: treeHash, + deployHash: deployHash, branch: 'main', bucket: 'my-bucket', key: 'deploys/main' }) - expect(output.treeHash).toBe(treeHash) + expect(output.deployHash).toBe(deployHash) expect(output.branchLabel).toBe('main') } ) @@ -53,9 +53,9 @@ describe(`Cursor Deploy Action`, () => { And the tree hash used is the current repo tree hash `, async () => { - const treeHash = '553b0cb96ac21ffc0583e5d8d72343b1faa90dfd' + const deployHash = '553b0cb96ac21ffc0583e5d8d72343b1faa90dfd' const sanitizedBranch = 'lol-my-feature-branch-30-better' - mockedUtils.getCurrentRepoTreeHash.mockResolvedValue(treeHash) + mockedUtils.readFileFromS3.mockResolvedValue(deployHash) mockedUtils.fileExistsInS3.mockResolvedValue(false) const output = await cursorDeploy({ @@ -68,13 +68,13 @@ describe(`Cursor Deploy Action`, () => { expectRollbackFileChecked('my-bucket', 'rollbacks/lol-my-feature-branch-30-better') expectCursorFileUpdated({ - treeHash: treeHash, + deployHash: deployHash, branch: sanitizedBranch, bucket: 'my-bucket', key: 'deploys/lol-my-feature-branch-30-better' }) - expect(output.treeHash).toBe(treeHash) + expect(output.deployHash).toBe(deployHash) expect(output.branchLabel).toBe(sanitizedBranch) } ) @@ -87,8 +87,8 @@ describe(`Cursor Deploy Action`, () => { And the action returns a error `, async () => { - const treeHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' - mockedUtils.getCurrentRepoTreeHash.mockResolvedValue(treeHash) + const deployHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' + mockedUtils.readFileFromS3.mockResolvedValue(deployHash) mockedUtils.fileExistsInS3.mockResolvedValue(true) const promise = cursorDeploy({ @@ -118,12 +118,10 @@ describe(`Cursor Deploy Action`, () => { And the tree hash used is the previous commit tree hash `, async () => { - const currentTreeHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' - const commitTreeHash = '32439d157a7e346d117a6a3c47d511526bd45012' + const currentDeployHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' - mockedUtils.getCurrentRepoTreeHash.mockResolvedValue(currentTreeHash) + mockedUtils.readFileFromS3.mockResolvedValue(currentDeployHash) mockedUtils.fileExistsInS3.mockResolvedValue(false) - mockedUtils.getTreeHashForCommitHash.mockResolvedValue(commitTreeHash) const output = await cursorDeploy({ bucket: 'my-prod-bucket', @@ -134,11 +132,11 @@ describe(`Cursor Deploy Action`, () => { expect(mockedUtils.fileExistsInS3).not.toHaveBeenCalled() expect(mockedUtils.isHeadAncestor).not.toHaveBeenCalled() - expect(mockedUtils.getTreeHashForCommitHash).toHaveBeenCalledWith('HEAD^') + expect(mockedUtils.getCommitHashFromRef).toHaveBeenCalledWith('HEAD^') expect(mockedUtils.writeLineToFile).toHaveBeenCalledTimes(1) expect(mockedUtils.writeLineToFile).toHaveBeenCalledWith({ - text: commitTreeHash, + text: currentDeployHash, path: 'main' }) @@ -155,7 +153,7 @@ describe(`Cursor Deploy Action`, () => { key: 'rollbacks/main' }) - expect(output.treeHash).toBe(commitTreeHash) + expect(output.deployHash).toBe(currentDeployHash) expect(output.branchLabel).toBe('main') } ) @@ -168,14 +166,12 @@ describe(`Cursor Deploy Action`, () => { And the tree hash used is the tree hash of the passed commit hash `, async () => { - const currentTreeHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' - const commitTreeHash = 'b6e1c0468f4705b8cd0f18a04cd28ef7b9da7425' + const currentDeployHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' const commitHash = 'fc24d309398cbf6d53237e05e4d2a8cd2de57cc7' - mockedUtils.getCurrentRepoTreeHash.mockResolvedValue(currentTreeHash) + mockedUtils.readFileFromS3.mockResolvedValue(currentDeployHash) mockedUtils.fileExistsInS3.mockResolvedValue(false) mockedUtils.isHeadAncestor.mockResolvedValue(true) - mockedUtils.getTreeHashForCommitHash.mockResolvedValue(commitTreeHash) const output = await cursorDeploy({ bucket: 'my-bucket', @@ -186,11 +182,11 @@ describe(`Cursor Deploy Action`, () => { expect(mockedUtils.fileExistsInS3).not.toHaveBeenCalled() expect(mockedUtils.isHeadAncestor).toHaveBeenCalledWith(commitHash) - expect(mockedUtils.getTreeHashForCommitHash).toHaveBeenCalledWith(commitHash) + expect(mockedUtils.getCommitHashFromRef).toHaveBeenCalledWith(commitHash) expect(mockedUtils.writeLineToFile).toHaveBeenCalledTimes(1) expect(mockedUtils.writeLineToFile).toHaveBeenCalledWith({ - text: commitTreeHash, + text: currentDeployHash, path: 'main' }) @@ -207,7 +203,7 @@ describe(`Cursor Deploy Action`, () => { key: 'rollbacks/main' }) - expect(output.treeHash).toBe(commitTreeHash) + expect(output.deployHash).toBe(currentDeployHash) expect(output.branchLabel).toBe('main') } ) @@ -221,9 +217,9 @@ describe(`Cursor Deploy Action`, () => { And the tree hash used is the tree hash of the passed commit hash `, async () => { - const treeHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' + const deployHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' - mockedUtils.getCurrentRepoTreeHash.mockResolvedValue(treeHash) + mockedUtils.readFileFromS3.mockResolvedValue(deployHash) mockedUtils.fileExistsInS3.mockResolvedValue(true) mockedUtils.isHeadAncestor.mockResolvedValue(true) @@ -237,7 +233,7 @@ describe(`Cursor Deploy Action`, () => { expectRollbackFileChecked('my-bucket', 'rollbacks/main') expectCursorFileUpdated({ - treeHash: treeHash, + deployHash: deployHash, branch: 'main', bucket: 'my-bucket', key: 'deploys/main' @@ -248,7 +244,7 @@ describe(`Cursor Deploy Action`, () => { key: 'rollbacks/main' }) - expect(output.treeHash).toBe(treeHash) + expect(output.deployHash).toBe(deployHash) expect(output.branchLabel).toBe('main') } ) @@ -282,14 +278,12 @@ describe(`Cursor Deploy Action`, () => { Then the action fails with an informative error `, async () => { - const currentTreeHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' + const currentDeployHash = 'b017ebdf289ba78787da4e9c3291f0b7959e7059' const commitHash = 'fc24d309398cbf6d53237e05e4d2a8cd2de57cc7' - const commitTreeHash = 'b6e1c0468f4705b8cd0f18a04cd28ef7b9da7425' - mockedUtils.getCurrentRepoTreeHash.mockResolvedValue(currentTreeHash) + mockedUtils.readFileFromS3.mockResolvedValue(currentDeployHash) mockedUtils.fileExistsInS3.mockResolvedValue(false) mockedUtils.isHeadAncestor.mockResolvedValue(false) - mockedUtils.getTreeHashForCommitHash.mockResolvedValue(commitTreeHash) const promise = cursorDeploy({ bucket: 'my-bucket', @@ -370,14 +364,14 @@ describe('Branch Sanitize - branchNameToHostnameLabel', () => { //#region Custom Assertions function expectCursorFileUpdated(args: { - treeHash: string + deployHash: string branch: string bucket: string key: string }) { expect(mockedUtils.writeLineToFile).toHaveBeenCalledTimes(1) expect(mockedUtils.writeLineToFile).toHaveBeenCalledWith({ - text: args.treeHash, + text: args.deployHash, path: args.branch }) diff --git a/actions/cursor-deploy/index.ts b/actions/cursor-deploy/index.ts index f3991e75..660c1276 100644 --- a/actions/cursor-deploy/index.ts +++ b/actions/cursor-deploy/index.ts @@ -15,9 +15,9 @@ import { fileExistsInS3, removeFileFromS3, runAction, - getCurrentRepoTreeHash, isHeadAncestor, - getTreeHashForCommitHash + readFileFromS3, + getCommitHashFromRef } from '../utils' const deployModes = ['default', 'rollback', 'unblock'] as const @@ -35,7 +35,7 @@ runAction(async () => { ref: github.context.payload?.pull_request?.head.ref ?? github.context.ref }) - core.setOutput('tree_hash', output.treeHash) + core.setOutput('deploy_hash', output.deployHash) core.setOutput('branch_label', output.branchLabel) }) @@ -54,7 +54,8 @@ export async function cursorDeploy({ }: CursorDeployActionArgs) { const deployMode = getDeployMode(deployModeInput) const branchLabel = branchNameToHostnameLabel(ref) - const treeHash = await getDeploymentHash(deployMode, rollbackCommitHash) + const commitHash = await getDeployCommitHash(deployMode, rollbackCommitHash) + const deployHash = await getDeploymentHashFromCommitHash(bucket, commitHash) const rollbackKey = `rollbacks/${branchLabel}` const deployKey = `deploys/${branchLabel}` @@ -77,10 +78,10 @@ export async function cursorDeploy({ } // Perform the deployment by updating the cursor file for the current branch to point - // to the desired tree hash - await writeLineToFile({text: treeHash, path: branchLabel}) + // to the desired deploy hash + await writeLineToFile({text: deployHash, path: branchLabel}) await copyFileToS3({path: branchLabel, bucket, key: deployKey}) - core.info(`Tree hash ${treeHash} is now the active deployment for ${branchLabel}.`) + core.info(`Deploy hash ${deployHash} is now the active deployment for ${branchLabel}.`) // If we're doing a rollback deployment we create a rollback file that blocks any following // deployments from going through. @@ -96,7 +97,7 @@ export async function cursorDeploy({ core.info(`${branchLabel} has automatic deploys resumed.`) } - return {treeHash, branchLabel} + return {deployHash, branchLabel} } /** @@ -118,31 +119,31 @@ function getDeployMode(deployMode: string) { return deployMode } -/** - * Establish the tree hash of the code to be deployed. If we're doing a rollback, - * we figure out the tree hash from the explicitly passed commit hash or the previous - * commit on the branch. We additionally validate if the input commit hash is a commit from - * the current branch, to make sure we can only rollback within the branch. - * Otherwise we use the head root tree hash on the current branch. - * @param deployMode - Deployment mode - * @param rollbackCommitHash - In rollback deploy mode, optional explicit commit hash to roll back to - * @returns treeHash - */ -async function getDeploymentHash(deployMode: DeployMode, rollbackCommitHash?: string) { +async function getDeployCommitHash(deployMode: DeployMode, rollbackCommitHash?: string) { if (deployMode === 'rollback') { + // Validate if the input commit hash is a commit from + // the current branch, to make sure we can only rollback within the branch. if (!!rollbackCommitHash && !(await isHeadAncestor(rollbackCommitHash))) { throw new Error('The selected rollback commit is not present on the branch') } // If no rollback commit is provided, we default to the previous commit on the branch - const commit = rollbackCommitHash || 'HEAD^' - const treeHash = await getTreeHashForCommitHash(commit) - core.info(`Rolling back to tree hash ${treeHash} (commit ${commit})`) - return treeHash + return getCommitHashFromRef(rollbackCommitHash || 'HEAD^') } + return getCommitHashFromRef('HEAD') +} - const treeHash = await getCurrentRepoTreeHash() - core.info(`Using current root tree hash ${treeHash}`) - return treeHash +// Get deploy hash for the provided commit hash +async function getDeploymentHashFromCommitHash(bucket: string, commitHash: string) { + const commitKey = `deploys/commits/${commitHash}` + const deployHash = await readFileFromS3({ + bucket: bucket, + key: commitKey + }) + if (!deployHash) { + throw Error(`Deploy hash for commit ${commitHash} not found.`) + } + core.info(`Using deploy hash ${deployHash} (commit ${commitHash})`) + return deployHash } export function branchNameToHostnameLabel(ref: string) { diff --git a/actions/utils.test.ts b/actions/utils.test.ts index bd1e1c19..40e3cba6 100644 --- a/actions/utils.test.ts +++ b/actions/utils.test.ts @@ -27,29 +27,6 @@ describe(`Actions Utils`, () => { expect(output).toBe(false) }) - test(`getTreeHashForCommitHash uses git CLI to check if the commit is part of the current branch, returns false when it is not`, async () => { - mockedExec.mockResolvedValue(0) - const hash = '5265ef99f1c8e18bcd282a11a4b752731cad5665' - const output = await utils.getTreeHashForCommitHash(hash) - expect(mockedExec).toHaveBeenCalledWith( - 'git rev-parse', - ['5265ef99f1c8e18bcd282a11a4b752731cad5665:'], - { - listeners: {stdout: expect.any(Function)} - } - ) - expect(output).toBe('') - }) - - test(`getCurrentRepoTreeHash uses git CLI to return the latest tree hash of the root of the repo`, async () => { - mockedExec.mockResolvedValue(0) - const output = await utils.getCurrentRepoTreeHash() - expect(mockedExec).toHaveBeenCalledWith('git rev-parse', ['HEAD:'], { - listeners: {stdout: expect.any(Function)} - }) - expect(output).toBe('') - }) - test(`writeLineToFile creates a file using a shell script`, async () => { mockedExec.mockResolvedValue(0) await utils.writeLineToFile({path: '/some/file', text: 'hello world'}) @@ -67,6 +44,15 @@ describe(`Actions Utils`, () => { expect(output).toBe(true) }) + test(`getCommitHashFromRef uses git CLI to return the latest tree hash of the root of the repo`, async () => { + mockedExec.mockResolvedValue(0) + const output = await utils.getCommitHashFromRef('HEAD') + expect(mockedExec).toHaveBeenCalledWith('git rev-parse', ['HEAD'], { + listeners: {stdout: expect.any(Function)} + }) + expect(output).toBe('') + }) + test(`fileExistsInS3 uses AWS CLI to check for of an object in S3 bucket, returns true if it exists`, async () => { mockedExec.mockRejectedValue(255) const output = await utils.fileExistsInS3({key: 'my/key', bucket: 'my-bucket'}) diff --git a/actions/utils.ts b/actions/utils.ts index 3fad915c..367db9cb 100644 --- a/actions/utils.ts +++ b/actions/utils.ts @@ -54,7 +54,7 @@ export async function writeLineToFile({text, path}: {text: string; path: string} } /** - * Uploads a local file at a specified path to a S3 bucket at a given given + * Uploads a local file at a specified path to a S3 bucket at a given key * Executes "aws s3 cp" * @param options.path - The local path of the file (relative to working dir) * @param options.key - The key of a file to create in the S3 bucket @@ -73,6 +73,24 @@ export async function copyFileToS3({ await exec('aws s3 cp', [path, `s3://${bucket}/${key}`]) } +/** + * Read a file from the S3 bucket at a given key + * Executes "aws s3 cp" + * @param options.key - The key of a file to read from the S3 bucket + * @param options.bucket - The name of the S3 bucket (globally unique) + * @returns exitCode - shell command exit code + */ +export async function readFileFromS3({key, bucket}: {key: string; bucket: string}) { + // Download file and store it in temporary file + // Unfortunately we can't use pipes with @actions/exec + await exec('aws s3 cp', [`s3://${bucket}/${key}`, 'tmp']) + // Read content of the file + const data = await execReadOutput('cat', ['tmp']) + // Remove the temporary file + await exec('rm', ['tmp']) + return data +} + /** * Deletes a file at a specified key from a given S3 bucket * Executes "aws s3 rm" @@ -111,20 +129,10 @@ export async function isHeadAncestor(commitHash: string) { } /** - * Retrieve the root tree hash for the provided commit identifier - * @param commit - commit identifier to lookup - * @returns treeHash - */ -export async function getTreeHashForCommitHash(commit: string) { - return execReadOutput('git rev-parse', [`${commit}:`]) -} - -/** - * Retrieves the current root tree hash of the git repository - * Tree hash captures the state of the whole directory tree - * of all the files in the repository. - * @returns treeHash - SHA-1 root tree hash + * Retrieves the commit hash for the provided ref + * ref can be either a specific commit, or HEAD + * @returns SHA-1 of the commit hash */ -export async function getCurrentRepoTreeHash() { - return getTreeHashForCommitHash('HEAD') +export async function getCommitHashFromRef(ref: string) { + return execReadOutput(`git rev-parse`, [ref]) } From 726dc65a8dc4affd3e6ab5a121ee8b9ea654cc5d Mon Sep 17 00:00:00 2001 From: Matej Vobornik Date: Wed, 11 Sep 2024 16:22:51 +0200 Subject: [PATCH 2/2] Adjust reusable workflows --- .github/workflows/build.yml | 7 +++- .github/workflows/deploy.yml | 66 ++++++++++++++++++++++++----------- reusable-workflows/build.yml | 7 +++- reusable-workflows/deploy.yml | 63 +++++++++++++++++++++++---------- 4 files changed, 103 insertions(+), 40 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd2509aa..42cc85ba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,10 @@ on: required: true description: "Name of the S3 registry bucket" type: string + custom_hash: + required: false + description: "Custom hash used to cache the action on successful build" + type: string build_dir: required: true description: "Location of the deploy bundle after running the build command" @@ -63,11 +67,12 @@ jobs: - uses: actions/checkout@v4.1.6 - name: Check S3 Cache - uses: pleo-io/s3-cache-action@v3.0.0 + uses: pleo-io/s3-cache-action@v3.1.0 id: s3-cache with: bucket-name: ${{ inputs.bucket_name }} key-prefix: build/${{ inputs.app_name }} + custom-hash: ${{ inputs.custom_hash }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_FRONTEND_REGISTRY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_FRONTEND_REGISTRY }} aws-region: eu-west-1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b4cd1ad7..6e9ca8d6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,7 +22,7 @@ on: default: "dist" description: "Directory where the bundle should be unpacked" type: string - tree_hash: + deploy_hash: required: true description: "Tree hash of the code to deploy" type: string @@ -65,6 +65,25 @@ jobs: registry-url: "https://npm.pkg.github.com" scope: ${{ inputs.registry_scope }} + - name: Setup AWS Credentials for Origin Bucket Access + uses: aws-actions/configure-aws-credentials@v4.0.2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-1 + + - name: Check version already deployed + id: is-version-already-deployed + run: | + DEPLOY_EXISTS=$(aws s3 ls s3://${{ inputs.bucket_name }}/html/${{ inputs.deploy_hash }}/ || true) + if [ -z "$DEPLOY_EXISTS" ]; then + echo "Version ${{ inputs.deploy_hash }} not yet deployed" + echo "is_deployed=false" >> $GITHUB_OUTPUT + else + echo "Version ${{ inputs.deploy_hash }} already deployed" + echo "is_deployed=true" >> $GITHUB_OUTPUT + fi + # For feature preview deployments we're using the permalink as the deploy URL for the GitHub deployment # For deployments on the default branch we're using the domain name passed via inputs. - name: Get Deployment URL @@ -75,17 +94,19 @@ jobs: echo "Could not determine default branch" exit 1 fi - SUBDOMAIN=$([[ $GITHUB_REF = "refs/heads/$DEFAULT_BRANCH" || $GITHUB_REF = "refs/heads/change-freeze-emergency-deploy" ]] && echo "" || echo "preview-${{ inputs.tree_hash }}.") + SUBDOMAIN=$([[ $GITHUB_REF = "refs/heads/$DEFAULT_BRANCH" || $GITHUB_REF = "refs/heads/change-freeze-emergency-deploy" ]] && echo "" || echo "preview-${{ inputs.deploy_hash }}.") echo "url=https://${SUBDOMAIN}${{ inputs.domain_name }}" >> $GITHUB_OUTPUT - name: Setup AWS Credentials for Registry Bucket Access uses: aws-actions/configure-aws-credentials@v4.0.2 + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_FRONTEND_REGISTRY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_FRONTEND_REGISTRY }} aws-region: eu-west-1 - name: Download & Unpack Bundle + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} run: | aws s3 cp ${{ inputs.bundle_uri }} bundle.tar.gz mkdir ${{ inputs.bundle_dir }} && tar -xvzf bundle.tar.gz -C ${{ inputs.bundle_dir }} @@ -98,45 +119,57 @@ jobs: aws-region: eu-west-1 - name: Inject Environment Config - if: inputs.inject_config_cmd + if: inputs.inject_config_cmd && ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} env: NODE_AUTH_TOKEN: ${{ secrets.GH_REGISTRY_NPM_TOKEN }} SPA_BUNDLE_DIR: ${{ inputs.bundle_dir }} SPA_ENV: ${{inputs.environment}} - SPA_TREE_HASH: ${{ inputs.tree_hash}} + SPA_TREE_HASH: ${{ inputs.deploy_hash}} run: ${{inputs.inject_config_cmd }} - name: Copy Static Files + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} run: | aws s3 cp ${{ inputs.bundle_dir }}/static s3://${{ inputs.bucket_name }}/static \ --cache-control 'public,max-age=31536000,immutable' \ --recursive - name: Copy HTML Files + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} run: | - aws s3 cp ${{ inputs.bundle_dir }}/ s3://${{ inputs.bucket_name }}/html/${{ inputs.tree_hash }} \ + aws s3 cp ${{ inputs.bundle_dir }}/ s3://${{ inputs.bucket_name }}/html/${{ inputs.deploy_hash }} \ --cache-control 'public,max-age=31536000,immutable' \ --recursive \ --exclude "static/*" - name: Update .well-known Files If Exists + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} run: | aws s3 cp \ - s3://${{ inputs.bucket_name }}/html/${{ inputs.tree_hash }}/.well-known/apple-app-site-association \ - s3://${{ inputs.bucket_name }}/html/${{ inputs.tree_hash }}/.well-known/apple-app-site-association \ + s3://${{ inputs.bucket_name }}/html/${{ inputs.deploy_hash }}/.well-known/apple-app-site-association \ + s3://${{ inputs.bucket_name }}/html/${{ inputs.deploy_hash }}/.well-known/apple-app-site-association \ --content-type 'application/json' \ --cache-control 'public,max-age=3600' \ --metadata-directive REPLACE || echo "Failed updating .well-known files" + # We always copy deploy hash file even if we don't do an actual deployemnt as part of this commit + # This is used to identify which version of the deployed app is associated with the current commit + # In case we trigger rollback to this commit, we know what deployment to use based on the stored deploy_hash for this commit + - name: Copy Deploy Hash File + run: | + echo ${{ inputs.deploy_hash }} >> ${{ github.sha }} + aws s3 cp ${{ github.sha }} s3://${{ inputs.bucket_name }}/deploys/commits/${{ github.sha }} + - name: Update Cursor File id: cursor-update - uses: pleo-io/spa-tools/actions/cursor-deploy@spa-github-actions-v9.0.2 + uses: pleo-io/spa-tools/actions/cursor-deploy@spa-github-actions-v10.0.0 + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} with: bucket_name: ${{ inputs.bucket_name }} - name: Update PR Description - uses: pleo-io/spa-tools/actions/post-preview-urls@spa-github-actions-v9.0.2 - if: github.event_name == 'pull_request' + uses: pleo-io/spa-tools/actions/post-preview-urls@spa-github-actions-v10.0.0 + if: github.event_name == 'pull_request' && ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} with: app_name: ${{ inputs.app_name }} links: | @@ -145,15 +178,8 @@ jobs: {"name": "Current Permalink", "url": "${{ steps.deployment-url.outputs.url }}"} ] - - name: Upload Deployed Bundle as Artifact - uses: actions/upload-artifact@v4.3.3 - with: - name: bundle-${{ inputs.tree_hash }}-${{ inputs.environment }} - path: bundle.tar.gz - - name: Verify app deployment - if: inputs.inject_config_cmd + if: inputs.inject_config_cmd && ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} # We expect the injection to insert the version in the HTML. - run: - curl --silent --show-error ${{ steps.deployment-url.outputs.url }} | - grep -q ${{ inputs.tree_hash }} + run: curl --silent --show-error ${{ steps.deployment-url.outputs.url }} | + grep -q ${{ inputs.deploy_hash }} diff --git a/reusable-workflows/build.yml b/reusable-workflows/build.yml index fd2509aa..42cc85ba 100644 --- a/reusable-workflows/build.yml +++ b/reusable-workflows/build.yml @@ -13,6 +13,10 @@ on: required: true description: "Name of the S3 registry bucket" type: string + custom_hash: + required: false + description: "Custom hash used to cache the action on successful build" + type: string build_dir: required: true description: "Location of the deploy bundle after running the build command" @@ -63,11 +67,12 @@ jobs: - uses: actions/checkout@v4.1.6 - name: Check S3 Cache - uses: pleo-io/s3-cache-action@v3.0.0 + uses: pleo-io/s3-cache-action@v3.1.0 id: s3-cache with: bucket-name: ${{ inputs.bucket_name }} key-prefix: build/${{ inputs.app_name }} + custom-hash: ${{ inputs.custom_hash }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_FRONTEND_REGISTRY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_FRONTEND_REGISTRY }} aws-region: eu-west-1 diff --git a/reusable-workflows/deploy.yml b/reusable-workflows/deploy.yml index b4cd1ad7..7c653b9c 100644 --- a/reusable-workflows/deploy.yml +++ b/reusable-workflows/deploy.yml @@ -22,7 +22,7 @@ on: default: "dist" description: "Directory where the bundle should be unpacked" type: string - tree_hash: + deploy_hash: required: true description: "Tree hash of the code to deploy" type: string @@ -65,6 +65,25 @@ jobs: registry-url: "https://npm.pkg.github.com" scope: ${{ inputs.registry_scope }} + - name: Setup AWS Credentials for Origin Bucket Access + uses: aws-actions/configure-aws-credentials@v4.0.2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-1 + + - name: Check version already deployed + id: is-version-already-deployed + run: | + DEPLOY_EXISTS=$(aws s3 ls s3://${{ inputs.bucket_name }}/html/${{ inputs.deploy_hash }}/ || true) + if [ -z "$DEPLOY_EXISTS" ]; then + echo "Version ${{ inputs.deploy_hash }} not yet deployed" + echo "is_deployed=false" >> $GITHUB_OUTPUT + else + echo "Version ${{ inputs.deploy_hash }} already deployed" + echo "is_deployed=true" >> $GITHUB_OUTPUT + fi + # For feature preview deployments we're using the permalink as the deploy URL for the GitHub deployment # For deployments on the default branch we're using the domain name passed via inputs. - name: Get Deployment URL @@ -75,17 +94,19 @@ jobs: echo "Could not determine default branch" exit 1 fi - SUBDOMAIN=$([[ $GITHUB_REF = "refs/heads/$DEFAULT_BRANCH" || $GITHUB_REF = "refs/heads/change-freeze-emergency-deploy" ]] && echo "" || echo "preview-${{ inputs.tree_hash }}.") + SUBDOMAIN=$([[ $GITHUB_REF = "refs/heads/$DEFAULT_BRANCH" || $GITHUB_REF = "refs/heads/change-freeze-emergency-deploy" ]] && echo "" || echo "preview-${{ inputs.deploy_hash }}.") echo "url=https://${SUBDOMAIN}${{ inputs.domain_name }}" >> $GITHUB_OUTPUT - name: Setup AWS Credentials for Registry Bucket Access uses: aws-actions/configure-aws-credentials@v4.0.2 + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID_FRONTEND_REGISTRY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_FRONTEND_REGISTRY }} aws-region: eu-west-1 - name: Download & Unpack Bundle + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} run: | aws s3 cp ${{ inputs.bundle_uri }} bundle.tar.gz mkdir ${{ inputs.bundle_dir }} && tar -xvzf bundle.tar.gz -C ${{ inputs.bundle_dir }} @@ -98,45 +119,57 @@ jobs: aws-region: eu-west-1 - name: Inject Environment Config - if: inputs.inject_config_cmd + if: inputs.inject_config_cmd && ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} env: NODE_AUTH_TOKEN: ${{ secrets.GH_REGISTRY_NPM_TOKEN }} SPA_BUNDLE_DIR: ${{ inputs.bundle_dir }} SPA_ENV: ${{inputs.environment}} - SPA_TREE_HASH: ${{ inputs.tree_hash}} + SPA_TREE_HASH: ${{ inputs.deploy_hash}} run: ${{inputs.inject_config_cmd }} - name: Copy Static Files + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} run: | aws s3 cp ${{ inputs.bundle_dir }}/static s3://${{ inputs.bucket_name }}/static \ --cache-control 'public,max-age=31536000,immutable' \ --recursive - name: Copy HTML Files + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} run: | - aws s3 cp ${{ inputs.bundle_dir }}/ s3://${{ inputs.bucket_name }}/html/${{ inputs.tree_hash }} \ + aws s3 cp ${{ inputs.bundle_dir }}/ s3://${{ inputs.bucket_name }}/html/${{ inputs.deploy_hash }} \ --cache-control 'public,max-age=31536000,immutable' \ --recursive \ --exclude "static/*" - name: Update .well-known Files If Exists + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} run: | aws s3 cp \ - s3://${{ inputs.bucket_name }}/html/${{ inputs.tree_hash }}/.well-known/apple-app-site-association \ - s3://${{ inputs.bucket_name }}/html/${{ inputs.tree_hash }}/.well-known/apple-app-site-association \ + s3://${{ inputs.bucket_name }}/html/${{ inputs.deploy_hash }}/.well-known/apple-app-site-association \ + s3://${{ inputs.bucket_name }}/html/${{ inputs.deploy_hash }}/.well-known/apple-app-site-association \ --content-type 'application/json' \ --cache-control 'public,max-age=3600' \ --metadata-directive REPLACE || echo "Failed updating .well-known files" + # We always copy deploy hash file even if we don't do an actual deployemnt as part of this commit + # This is used to identify which version of the deployed app is associated with the current commit + # In case we trigger rollback to this commit, we know what deployment to use based on the stored deploy_hash for this commit + - name: Copy Deploy Hash File + run: | + echo ${{ inputs.deploy_hash }} >> ${{ github.sha }} + aws s3 cp ${{ github.sha }} s3://${{ inputs.bucket_name }}/deploys/commits/${{ github.sha }} + - name: Update Cursor File id: cursor-update - uses: pleo-io/spa-tools/actions/cursor-deploy@spa-github-actions-v9.0.2 + uses: pleo-io/spa-tools/actions/cursor-deploy@spa-github-actions-v10.0.0 + if: ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} with: bucket_name: ${{ inputs.bucket_name }} - name: Update PR Description - uses: pleo-io/spa-tools/actions/post-preview-urls@spa-github-actions-v9.0.2 - if: github.event_name == 'pull_request' + uses: pleo-io/spa-tools/actions/post-preview-urls@spa-github-actions-v10.0.0 + if: github.event_name == 'pull_request' && ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} with: app_name: ${{ inputs.app_name }} links: | @@ -145,15 +178,9 @@ jobs: {"name": "Current Permalink", "url": "${{ steps.deployment-url.outputs.url }}"} ] - - name: Upload Deployed Bundle as Artifact - uses: actions/upload-artifact@v4.3.3 - with: - name: bundle-${{ inputs.tree_hash }}-${{ inputs.environment }} - path: bundle.tar.gz - - name: Verify app deployment - if: inputs.inject_config_cmd + if: inputs.inject_config_cmd && ${{ steps.is-version-already-deployed.outputs.is_deployed == 'false'}} # We expect the injection to insert the version in the HTML. run: curl --silent --show-error ${{ steps.deployment-url.outputs.url }} | - grep -q ${{ inputs.tree_hash }} + grep -q ${{ inputs.deploy_hash }}