├── .prettierignore ├── .gitattributes ├── .vscode └── settings.json ├── jest.config.js ├── .prettierrc.json ├── .github └── workflows │ ├── build.yml │ └── test.yml ├── tsconfig.json ├── LICENSE ├── eslint.config.mjs ├── action.yml ├── package.json ├── __tests__ └── main.test.ts ├── .env.example ├── .gitignore ├── README.md ├── src └── main.ts └── misc └── rollout-transform.yaml /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | dist/** -diff linguist-generated=true 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "arrowParens": "avoid", 10 | "parser": "typescript" 11 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "build" 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | - 'releases/*' 8 | 9 | jobs: 10 | build: # make sure build/ci work properly 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run: | 15 | npm install 16 | npm run all 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "test" 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | - 'releases/*' 8 | 9 | jobs: 10 | test: # make sure the action works on a clean machine without building 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: ./ 15 | with: 16 | argocd-token: foobar-bazqux 17 | github-token: ${{ github.token }} 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "strict": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | "skipLibCheck": true 11 | }, 12 | "exclude": ["node_modules", "**/*.test.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 GitHub, Inc. and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import pluginJest from 'eslint-plugin-jest'; 4 | import pluginGithub from 'eslint-plugin-github' 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | tseslint.configs.strict, 9 | tseslint.configs.stylistic, 10 | pluginGithub.getFlatConfigs().recommended, 11 | ...pluginGithub.getFlatConfigs().typescript, 12 | { 13 | files: ['src/**/*.ts'], 14 | rules: { 15 | 'i18n-text/no-en': 'off', 16 | 'importPlugin/no-namespace': 'off', 17 | 'github/array-foreach': 'error', 18 | 'github/async-preventdefault': 'warn', 19 | 'github/no-then': 'error', 20 | 'github/no-blur': 'error', 21 | }, 22 | }, 23 | { 24 | files: ['**/*.test.ts'], 25 | plugins: { jest: pluginJest }, 26 | languageOptions: { 27 | globals: pluginJest.environments.globals.globals, 28 | }, 29 | rules: { 30 | 'jest/no-disabled-tests': 'warn', 31 | 'jest/no-focused-tests': 'error', 32 | 'jest/no-identical-title': 'error', 33 | 'jest/prefer-to-have-length': 'warn', 34 | 'jest/valid-expect': 'error', 35 | }, 36 | }, 37 | { 38 | ignores: ['dist/', 'lib/', 'node_modules/'] 39 | } 40 | ); 41 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'ArgoCD Diff' 2 | description: 'Diffs all ArgoCD apps in the repo, and provides the diff as a PR comment' 3 | author: 'Quizlet' 4 | inputs: 5 | argocd-server-url: 6 | description: ArgoCD server url (without the protocol) 7 | default: argo-cd-argocd-server.argo-cd 8 | required: false 9 | argocd-token: 10 | description: ArgoCD token for a local or project-scoped user https://argoproj.github.io/argo-cd/operator-manual/user-management/#local-usersaccounts-v15 11 | required: true 12 | argocd-version: 13 | description: ArgoCD Version 14 | default: v1.6.1 15 | required: false 16 | github-token: 17 | description: Github Token 18 | required: true 19 | argocd-extra-cli-args: 20 | description: Extra arguments to pass to the argocd CLI 21 | default: --grpc-web 22 | required: false 23 | plaintext: 24 | description: Whether to use HTTPS 25 | default: 'false' 26 | required: false 27 | environment: 28 | description: Name of env to use in the diff title posted to the PR 29 | default: legacy 30 | required: false 31 | app-name-matcher: 32 | description: Comma-separated list or '/'-delimited regex of app names to include in diff output 33 | default: "" 34 | required: false 35 | runs: 36 | using: 'node20' 37 | main: 'dist/index.js' 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "argocd-diff-action", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "ArgoCD Diff GitHub Action", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "tsc", 9 | "format": "prettier --write **/*.ts", 10 | "format-check": "prettier --check **/*.ts", 11 | "lint": "eslint src/**/*.ts", 12 | "pack": "ncc build", 13 | "test": "jest", 14 | "all": "npm run build && npm run format && npm run lint && npm run pack && npm run test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/quizlet/argocd-diff-action.git" 19 | }, 20 | "keywords": [ 21 | "actions", 22 | "node", 23 | "setup", 24 | "argocd" 25 | ], 26 | "author": "Quizlet", 27 | "license": "MIT", 28 | "dependencies": { 29 | "@actions/core": "^1.4.0", 30 | "@actions/exec": "^1.0.4", 31 | "@actions/github": "^5.0.0", 32 | "@actions/tool-cache": "^1.7.1", 33 | "node-fetch": "^2.6.1" 34 | }, 35 | "devDependencies": { 36 | "@eslint/js": "^9.16.0", 37 | "@types/jest": "^29.5.14", 38 | "@types/node": "^20", 39 | "@types/node-fetch": "^2.5.10", 40 | "@vercel/ncc": "^0.38.3", 41 | "eslint": "^9.16.0", 42 | "eslint-plugin-github": "^5.1.3", 43 | "eslint-plugin-jest": "^28.9.0", 44 | "jest": "^29.7.0", 45 | "prettier": "^3.4.2", 46 | "ts-jest": "^29.2.5", 47 | "typescript": "^5.7.2", 48 | "typescript-eslint": "^8.17.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import { run, filterAppsByName, type App } from '../src/main'; 3 | 4 | describe('Action', () => { 5 | // shows how the runner will run a javascript action with env / stdout protocol 6 | test('runs', async () => { 7 | process.env['RUNNER_TEMP'] = os.tmpdir(); 8 | process.env['GITHUB_REPOSITORY'] = 'quizlet/cd-infra'; 9 | process.env['INPUT_GITHUB-TOKEN'] = '500'; 10 | process.env['INPUT_ARGOCD-VERSION'] = 'v1.6.1'; 11 | process.env['INPUT_ARGOCD-SERVER-URL'] = 'argocd.qzlt.io'; 12 | process.env['INPUT_ARGOCD-TOKEN'] = 'foo'; 13 | expect(run()).rejects.toThrow(); 14 | }); 15 | 16 | describe('matches app names', () => { 17 | const makeApp = (name: string) => ({ metadata: { name } }) as App; 18 | 19 | test('allows all apps when matcher is empty', () => { 20 | expect(filterAppsByName([makeApp('foobar'), makeApp('bazqux')], '')).toEqual([ 21 | makeApp('foobar'), 22 | makeApp('bazqux') 23 | ]); 24 | }); 25 | 26 | test('allows only apps when matcher is provided', () => { 27 | expect(filterAppsByName([makeApp('foobar'), makeApp('bazqux')], 'foobar')).toEqual([ 28 | makeApp('foobar') 29 | ]); 30 | }); 31 | 32 | test('treats matcher as regex when it is delimited by slashes', () => { 33 | expect(filterAppsByName([makeApp('foobar'), makeApp('bazqux')], '/bar$/')).toEqual([ 34 | makeApp('foobar') 35 | ]); 36 | }); 37 | 38 | test('with negative lookahead in regex', () => { 39 | expect(filterAppsByName([makeApp('foobar'), makeApp('bazqux')], '/^(?!foobar$).*$/')).toEqual( 40 | [makeApp('bazqux')] 41 | ); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Do not commit your actual .env file to Git! This may contain secrets or other 2 | # private information. 3 | 4 | # Enable/disable step debug logging (default: `false`). For local debugging, it 5 | # may be useful to set it to `true`. 6 | ACTIONS_STEP_DEBUG=true 7 | 8 | # GitHub Actions inputs should follow `INPUT_` format (case-sensitive). 9 | # Hyphens should not be converted to underscores! 10 | # INPUT_MILLISECONDS=2400 11 | 12 | # GitHub Actions default environment variables. These are set for every run of a 13 | # workflow and can be used in your actions. Setting the value here will override 14 | # any value set by the local-action tool. 15 | # https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables 16 | 17 | # CI="true" 18 | # GITHUB_ACTION="" 19 | # GITHUB_ACTION_PATH="" 20 | # GITHUB_ACTION_REPOSITORY="" 21 | # GITHUB_ACTIONS="" 22 | # GITHUB_ACTOR="" 23 | # GITHUB_ACTOR_ID="" 24 | # GITHUB_API_URL="" 25 | # GITHUB_BASE_REF="" 26 | # GITHUB_ENV="" 27 | # GITHUB_EVENT_NAME="" 28 | # GITHUB_EVENT_PATH="" 29 | # GITHUB_GRAPHQL_URL="" 30 | # GITHUB_HEAD_REF="" 31 | # GITHUB_JOB="" 32 | # GITHUB_OUTPUT="" 33 | # GITHUB_PATH="" 34 | # GITHUB_REF="" 35 | # GITHUB_REF_NAME="" 36 | # GITHUB_REF_PROTECTED="" 37 | # GITHUB_REF_TYPE="" 38 | # GITHUB_REPOSITORY="" 39 | # GITHUB_REPOSITORY_ID="" 40 | # GITHUB_REPOSITORY_OWNER="" 41 | # GITHUB_REPOSITORY_OWNER_ID="" 42 | # GITHUB_RETENTION_DAYS="" 43 | # GITHUB_RUN_ATTEMPT="" 44 | # GITHUB_RUN_ID="" 45 | # GITHUB_RUN_NUMBER="" 46 | # GITHUB_SERVER_URL="" 47 | # GITHUB_SHA="" 48 | # GITHUB_STEP_SUMMARY="" 49 | # GITHUB_TRIGGERING_ACTOR="" 50 | # GITHUB_WORKFLOW="" 51 | # GITHUB_WORKFLOW_REF="" 52 | # GITHUB_WORKFLOW_SHA="" 53 | # GITHUB_WORKSPACE="" 54 | # RUNNER_ARCH="" 55 | # RUNNER_DEBUG="" 56 | # RUNNER_NAME="" 57 | # RUNNER_OS="" 58 | # RUNNER_TEMP="" 59 | # RUNNER_TOOL_CACHE="" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | lib/**/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ArgoCD Diff GitHub Action 2 | 3 | ![](https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/Warning.svg/156px-Warning.svg.png) | This project is no longer supported. 4 | |---|---| 5 | 6 | > **IMPORTANT NOTE:** As of 2025, this action has been deprecated and is no longer being actively maintained. Please see https://github.com/argocd-diff-action/argocd-diff-action for a popular and actively maintained fork of this action. 7 | 8 | This action generates a diff between the current PR and the current state of the cluster. 9 | 10 | Note that this includes any changes between your branch and latest `master`, as well as ways in which the cluster is out of sync. 11 | 12 | ## Usage 13 | 14 | Example GH action: 15 | ```yaml 16 | name: ArgoCD Diff 17 | 18 | on: 19 | pull_request: 20 | branches: [master, main] 21 | 22 | jobs: 23 | argocd-diff: 24 | name: Generate ArgoCD Diff 25 | runs-on: ubuntu-latest 26 | steps: 27 | 28 | - name: Checkout repo 29 | uses: actions/checkout@v4 30 | 31 | - uses: quizlet/argocd-diff-action@master 32 | name: ArgoCD Diff 33 | with: 34 | argocd-server-url: argocd.example.com 35 | argocd-token: ${{ secrets.ARGOCD_TOKEN }} 36 | github-token: ${{ secrets.GITHUB_TOKEN }} 37 | argocd-version: v1.6.1 38 | argocd-extra-cli-args: --grpc-web # optional 39 | app-name-matcher: "/^myapp-/" # optional 40 | plaintext: true # optional 41 | environment: myenv # optional 42 | ``` 43 | 44 | ## How it works 45 | 46 | 1. Downloads the specified version of the ArgoCD binary, and makes it executable 47 | 2. Connects to the ArgoCD API using the `argocd-token`, and gets all the apps 48 | 3. Filters the apps to the ones that live in the current repo 49 | 4. Runs `argocd app diff` for each app 50 | 5. Posts the diff output as a comment on the PR 51 | 52 | ## Publishing 53 | 54 | Build the script and commit to your branch: 55 | `npm run build && npm run pack` 56 | Commit the build output, and make a PR. 57 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as tc from '@actions/tool-cache'; 3 | import { exec, ExecException, ExecOptions } from 'child_process'; 4 | import * as github from '@actions/github'; 5 | import fs from 'fs'; 6 | import path from 'path'; 7 | import nodeFetch from 'node-fetch'; 8 | 9 | interface ExecResult { 10 | err?: Error; 11 | stdout: string; 12 | stderr: string; 13 | } 14 | 15 | interface Diff { 16 | app: App; 17 | diff: string; 18 | error?: ExecResult; 19 | } 20 | 21 | export interface App { 22 | metadata: { name: string }; 23 | spec: { 24 | source: { 25 | repoURL: string; 26 | path: string; 27 | targetRevision: string; 28 | kustomize: object; 29 | helm: object; 30 | }; 31 | }; 32 | status: { 33 | sync: { 34 | status: 'OutOfSync' | 'Synced'; 35 | }; 36 | }; 37 | } 38 | 39 | export function filterAppsByName(appsAffected: App[], appNameMatcher: string): App[] { 40 | if (appNameMatcher.startsWith('/') && appNameMatcher.endsWith('/')) { 41 | const appNameFilter = new RegExp(appNameMatcher.slice(1, -1)); 42 | return appsAffected.filter(app => appNameFilter.test(app.metadata.name)); 43 | } else if (appNameMatcher !== '') { 44 | const appNames = new Set(appNameMatcher.split(',')); 45 | return appsAffected.filter(app => appNames.has(app.metadata.name)); 46 | } 47 | return appsAffected; 48 | } 49 | 50 | export async function run(): Promise { 51 | const ARCH = process.env.ARCH || 'linux'; 52 | const githubToken = core.getInput('github-token'); 53 | core.info(githubToken); 54 | 55 | const ARGOCD_SERVER_URL = core.getInput('argocd-server-url'); 56 | const ARGOCD_TOKEN = core.getInput('argocd-token'); 57 | const VERSION = core.getInput('argocd-version'); 58 | const ENV = core.getInput('environment'); 59 | const PLAINTEXT = core.getInput('plaintext').toLowerCase() === 'true'; 60 | const APP_NAME_MATCHER = core.getInput('app-name-matcher'); 61 | let EXTRA_CLI_ARGS = core.getInput('argocd-extra-cli-args'); 62 | if (PLAINTEXT) { 63 | EXTRA_CLI_ARGS += ' --plaintext'; 64 | } 65 | 66 | const octokit = github.getOctokit(githubToken); 67 | 68 | function execCommand(command: string, options: ExecOptions = {}): Promise { 69 | return new Promise((done, failed) => { 70 | exec(command, options, (err: ExecException | null, stdout: string, stderr: string): void => { 71 | const res: ExecResult = { 72 | stdout, 73 | stderr 74 | }; 75 | if (err) { 76 | res.err = err; 77 | failed(res); 78 | return; 79 | } 80 | done(res); 81 | }); 82 | }); 83 | } 84 | 85 | function scrubSecrets(input: string): string { 86 | let output = input; 87 | const authTokenMatches = input.match(/--auth-token=([\w.\S]+)/); 88 | if (authTokenMatches) { 89 | output = output.replace(new RegExp(authTokenMatches[1], 'g'), '***'); 90 | } 91 | return output; 92 | } 93 | 94 | async function setupArgoCDCommand(): Promise<(params: string) => Promise> { 95 | const argoBinaryPath = await tc.downloadTool( 96 | `https://github.com/argoproj/argo-cd/releases/download/${VERSION}/argocd-${ARCH}-amd64` 97 | ); 98 | fs.chmodSync(argoBinaryPath, '755'); 99 | 100 | return async (params: string) => 101 | execCommand( 102 | `${argoBinaryPath} ${params} --auth-token=${ARGOCD_TOKEN} --server=${ARGOCD_SERVER_URL} ${EXTRA_CLI_ARGS}` 103 | ); 104 | } 105 | 106 | async function getApps(): Promise { 107 | const protocol = PLAINTEXT ? 'http' : 'https'; 108 | const url = `${protocol}://${ARGOCD_SERVER_URL}/api/v1/applications`; 109 | core.info(`Fetching apps from: ${url}`); 110 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 111 | let responseJson: any; 112 | try { 113 | const response = await nodeFetch(url, { 114 | method: 'GET', 115 | headers: { Cookie: `argocd.token=${ARGOCD_TOKEN}` } 116 | }); 117 | responseJson = await response.json(); 118 | } catch (e) { 119 | if (e instanceof Error || typeof e === 'string') { 120 | core.setFailed(e); 121 | } 122 | return []; 123 | } 124 | const apps = responseJson.items as App[]; 125 | const repoApps = apps.filter(app => { 126 | const targetRevision = app.spec.source.targetRevision; 127 | const targetPrimary = 128 | targetRevision === 'master' || targetRevision === 'main' || !targetRevision; 129 | return ( 130 | app.spec.source.repoURL.includes( 131 | `${github.context.repo.owner}/${github.context.repo.repo}` 132 | ) && targetPrimary 133 | ); 134 | }); 135 | 136 | const changedFiles = await getChangedFiles(); 137 | core.info(`Changed files: ${changedFiles.join(', ')}`); 138 | const appsAffected = repoApps.filter(partOfApp.bind(null, changedFiles)); 139 | return filterAppsByName(appsAffected, APP_NAME_MATCHER); 140 | } 141 | 142 | async function postDiffComment(diffs: Diff[]): Promise { 143 | const protocol = PLAINTEXT ? 'http' : 'https'; 144 | const { owner, repo } = github.context.repo; 145 | const sha = github.context.payload.pull_request?.head?.sha; 146 | 147 | const commitLink = `https://github.com/${owner}/${repo}/pull/${github.context.issue.number}/commits/${sha}`; 148 | const shortCommitSha = String(sha).slice(0, 7); 149 | 150 | const filteredDiffs = diffs 151 | .map(diff => { 152 | diff.diff = filterDiff(diff.diff); 153 | return diff; 154 | }) 155 | .filter(d => d.diff !== ''); 156 | 157 | const prefixHeader = `## ArgoCD Diff on ${ENV}`; 158 | const diffOutput = filteredDiffs.map( 159 | ({ app, diff, error }) => ` 160 | App: [\`${app.metadata.name}\`](${protocol}://${ARGOCD_SERVER_URL}/applications/${ 161 | app.metadata.name 162 | }) 163 | YAML generation: ${error ? ' Error 🛑' : 'Success 🟢'} 164 | App sync status: ${app.status.sync.status === 'Synced' ? 'Synced ✅' : 'Out of Sync ⚠️ '} 165 | ${ 166 | error 167 | ? ` 168 | **\`stderr:\`** 169 | \`\`\` 170 | ${error.stderr} 171 | \`\`\` 172 | 173 | **\`command:\`** 174 | \`\`\`json 175 | ${JSON.stringify(error.err)} 176 | \`\`\` 177 | ` 178 | : '' 179 | } 180 | 181 | ${ 182 | diff 183 | ? ` 184 | 185 | \`\`\`diff 186 | ${diff} 187 | \`\`\` 188 | 189 | ` 190 | : '' 191 | } 192 | --- 193 | ` 194 | ); 195 | 196 | const output = scrubSecrets(` 197 | ${prefixHeader} for commit [\`${shortCommitSha}\`](${commitLink}) 198 | _Updated at ${new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })} PT_ 199 | ${diffOutput.join('\n')} 200 | 201 | | Legend | Status | 202 | | :---: | :--- | 203 | | ✅ | The app is synced in ArgoCD, and diffs you see are solely from this PR. | 204 | | ⚠️ | The app is out-of-sync in ArgoCD, and the diffs you see include those changes plus any from this PR. | 205 | | 🛑 | There was an error generating the ArgoCD diffs due to changes in this PR. | 206 | `); 207 | 208 | const commentsResponse = await octokit.rest.issues.listComments({ 209 | issue_number: github.context.issue.number, 210 | owner, 211 | repo 212 | }); 213 | 214 | // Delete stale comments 215 | for (const comment of commentsResponse.data) { 216 | if (comment.body?.includes(prefixHeader)) { 217 | core.info(`deleting comment ${comment.id}`); 218 | octokit.rest.issues.deleteComment({ 219 | owner, 220 | repo, 221 | comment_id: comment.id 222 | }); 223 | } 224 | } 225 | 226 | // Only post a new comment when there are changes 227 | if (filteredDiffs.length) { 228 | octokit.rest.issues.createComment({ 229 | issue_number: github.context.issue.number, 230 | owner, 231 | repo, 232 | body: output 233 | }); 234 | } 235 | } 236 | 237 | async function getChangedFiles(): Promise { 238 | const { owner, repo } = github.context.repo; 239 | const pull_number = github.context.issue.number; 240 | 241 | const listFilesResponse = await octokit.rest.pulls.listFiles({ 242 | owner, 243 | repo, 244 | pull_number 245 | }); 246 | 247 | return listFilesResponse.data.map(file => file.filename); 248 | } 249 | 250 | function partOfApp(changedFiles: string[], app: App): boolean { 251 | const sourcePath = path.normalize(app.spec.source.path); 252 | const appPath = getFirstTwoDirectories(sourcePath); 253 | 254 | return changedFiles.some(file => { 255 | const normalizedFilePath = path.normalize(file); 256 | return normalizedFilePath.startsWith(appPath); 257 | }); 258 | } 259 | 260 | function getFirstTwoDirectories(filePath: string): string { 261 | const normalizedPath = path.normalize(filePath); 262 | const parts = normalizedPath.split(path.sep).filter(Boolean); // filter(Boolean) removes empty strings 263 | if (parts.length < 2) { 264 | return parts.join(path.sep); // Return the entire path if less than two directories 265 | } 266 | return parts.slice(0, 2).join(path.sep); 267 | } 268 | 269 | async function asyncForEach( 270 | array: T[], 271 | callback: (item: T, i: number, arr: T[]) => Promise 272 | ): Promise { 273 | for (let index = 0; index < array.length; index++) { 274 | await callback(array[index], index, array); 275 | } 276 | } 277 | 278 | const argocd = await setupArgoCDCommand(); 279 | const apps = await getApps(); 280 | core.info(`Found apps: ${apps.map(a => a.metadata.name).join(', ')}`); 281 | 282 | const diffs: Diff[] = []; 283 | 284 | await asyncForEach(apps, async app => { 285 | const command = `app diff ${app.metadata.name} --local=${app.spec.source.path}`; 286 | try { 287 | core.info(`Running: argocd ${command}`); 288 | // ArgoCD app diff will exit 1 if there is a diff, so always catch, 289 | // and then consider it a success if there's a diff in stdout 290 | // https://github.com/argoproj/argo-cd/issues/3588 291 | await argocd(command); 292 | } catch (e) { 293 | const res = e as ExecResult; 294 | core.info(`stdout: ${res.stdout}`); 295 | core.info(`stderr: ${res.stderr}`); 296 | if (res.stdout) { 297 | diffs.push({ app, diff: res.stdout }); 298 | } else { 299 | diffs.push({ 300 | app, 301 | diff: '', 302 | error: res 303 | }); 304 | } 305 | } 306 | }); 307 | await postDiffComment(diffs); 308 | const diffsWithErrors = diffs.filter(d => d.error); 309 | if (diffsWithErrors.length) { 310 | core.setFailed(`ArgoCD diff failed: Encountered ${diffsWithErrors.length} errors`); 311 | } 312 | } 313 | 314 | function filterDiff(diffText: string): string { 315 | // Split the diff text into sections based on the headers 316 | const sections = diffText.split(/(?=^===== )/m); 317 | 318 | const filteredSection = sections 319 | .map(section => 320 | section 321 | .replace( 322 | /(\d+(,\d+)?c\d+(,\d+)?\n)?<\s+argocd\.argoproj\.io\/instance:.*\n---\n>\s+argocd\.argoproj\.io\/instance:.*\n?/g, 323 | '' 324 | ) 325 | .trim() 326 | .replace(/(\d+(,\d+)?c\d+(,\d+)?\n)?<\s+app.kubernetes.io\/part-of:.*\n?/g, '') 327 | .trim() 328 | ) 329 | .filter(section => section !== ''); 330 | 331 | // Remove empty strings and sections that are just headers with line numbers 332 | const removeEmptyHeaders = filteredSection.filter(entry => !entry.match(/^===== .*\/.* ======$/)); 333 | 334 | // Join the filtered sections back together 335 | return removeEmptyHeaders.join('\n').trim(); 336 | } 337 | 338 | // Avoid executing main automatically during tests 339 | if (require.main === module) { 340 | // eslint-disable-next-line github/no-then 341 | run().catch(e => core.setFailed(e.message)); 342 | } 343 | -------------------------------------------------------------------------------- /misc/rollout-transform.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/kubernetes-sigs/kustomize/blob/master/api/konfig/builtinpluginconsts/namereference.go 2 | nameReference: 3 | - kind: ConfigMap 4 | version: v1 5 | fieldSpecs: 6 | - path: spec/template/spec/volumes/configMap/name 7 | kind: Rollout 8 | - path: spec/template/spec/containers/env/valueFrom/configMapKeyRef/name 9 | kind: Rollout 10 | - path: spec/template/spec/initContainers/env/valueFrom/configMapKeyRef/name 11 | kind: Rollout 12 | - path: spec/template/spec/containers/envFrom/configMapRef/name 13 | kind: Rollout 14 | - path: spec/template/spec/initContainers/envFrom/configMapRef/name 15 | kind: Rollout 16 | - path: spec/template/spec/volumes/projected/sources/configMap/name 17 | kind: Rollout 18 | - path: spec/templates/template/spec/volumes/configMap/name 19 | kind: Experiment 20 | - path: spec/templates/template/spec/containers/env/valueFrom/configMapKeyRef/name 21 | kind: Experiment 22 | - path: spec/templates/template/spec/initContainers/env/valueFrom/configMapKeyRef/name 23 | kind: Experiment 24 | - path: spec/templates/template/spec/containers/envFrom/configMapRef/name 25 | kind: Experiment 26 | - path: spec/templates/template/spec/initContainers/envFrom/configMapRef/name 27 | kind: Experiment 28 | - path: spec/templates/template/spec/volumes/projected/sources/configMap/name 29 | kind: Experiment 30 | - path: spec/metrics/provider/job/spec/template/spec/volumes/configMap/name 31 | kind: AnalysisTemplate 32 | - path: spec/metrics/provider/job/spec/template/spec/containers/env/valueFrom/configMapKeyRef/name 33 | kind: AnalysisTemplate 34 | - path: spec/metrics/provider/job/spec/template/spec/initContainers/env/valueFrom/configMapKeyRef/name 35 | kind: AnalysisTemplate 36 | - path: spec/metrics/provider/job/spec/template/spec/containers/envFrom/configMapRef/name 37 | kind: AnalysisTemplate 38 | - path: spec/metrics/provider/job/spec/template/spec/initContainers/envFrom/configMapRef/name 39 | kind: AnalysisTemplate 40 | - path: spec/metrics/provider/job/spec/template/spec/volumes/projected/sources/configMap/name 41 | kind: AnalysisTemplate 42 | - kind: Secret 43 | version: v1 44 | fieldSpecs: 45 | - path: spec/template/spec/volumes/secret/secretName 46 | kind: Rollout 47 | - path: spec/template/spec/containers/env/valueFrom/secretKeyRef/name 48 | kind: Rollout 49 | - path: spec/template/spec/initContainers/env/valueFrom/secretKeyRef/name 50 | kind: Rollout 51 | - path: spec/template/spec/containers/envFrom/secretRef/name 52 | kind: Rollout 53 | - path: spec/template/spec/initContainers/envFrom/secretRef/name 54 | kind: Rollout 55 | - path: spec/template/spec/imagePullSecrets/name 56 | kind: Rollout 57 | - path: spec/template/spec/volumes/projected/sources/secret/name 58 | kind: Rollout 59 | - path: spec/templates/template/spec/volumes/secret/secretName 60 | kind: Experiment 61 | - path: spec/templates/template/spec/containers/env/valueFrom/secretKeyRef/name 62 | kind: Experiment 63 | - path: spec/templates/template/spec/initContainers/env/valueFrom/secretKeyRef/name 64 | kind: Experiment 65 | - path: spec/templates/template/spec/containers/envFrom/secretRef/name 66 | kind: Experiment 67 | - path: spec/templates/template/spec/initContainers/envFrom/secretRef/name 68 | kind: Experiment 69 | - path: spec/templates/template/spec/imagePullSecrets/name 70 | kind: Experiment 71 | - path: spec/templates/template/spec/volumes/projected/sources/secret/name 72 | kind: Experiment 73 | - path: spec/metrics/provider/job/spec/template/spec/volumes/secret/secretName 74 | kind: AnalysisTemplate 75 | - path: spec/metrics/provider/job/spec/template/spec/containers/env/valueFrom/secretKeyRef/name 76 | kind: AnalysisTemplate 77 | - path: spec/metrics/provider/job/spec/template/spec/initContainers/env/valueFrom/secretKeyRef/name 78 | kind: AnalysisTemplate 79 | - path: spec/metrics/provider/job/spec/template/spec/containers/envFrom/secretRef/name 80 | kind: AnalysisTemplate 81 | - path: spec/metrics/provider/job/spec/template/spec/initContainers/envFrom/secretRef/name 82 | kind: AnalysisTemplate 83 | - path: spec/metrics/provider/job/spec/template/spec/imagePullSecrets/name 84 | kind: AnalysisTemplate 85 | - path: spec/metrics/provider/job/spec/template/spec/volumes/projected/sources/secret/name 86 | kind: AnalysisTemplate 87 | - kind: ServiceAccount 88 | version: v1 89 | fieldSpecs: 90 | - path: spec/template/spec/serviceAccountName 91 | kind: Rollout 92 | - path: spec/templates/template/spec/serviceAccountName 93 | kind: Experiment 94 | - path: spec/metrics/provider/job/spec/template/spec/serviceAccountName 95 | kind: AnalysisTemplate 96 | - kind: PersistentVolumeClaim 97 | version: v1 98 | fieldSpecs: 99 | - path: spec/template/spec/volumes/persistentVolumeClaim/claimName 100 | kind: Rollout 101 | - path: spec/templates/template/spec/volumes/persistentVolumeClaim/claimName 102 | kind: Experiment 103 | - path: spec/metrics/provider/job/spec/template/spec/volumes/persistentVolumeClaim/claimName 104 | kind: AnalysisTemplate 105 | - kind: PriorityClass 106 | version: v1 107 | group: scheduling.k8s.io 108 | fieldSpecs: 109 | - path: spec/template/spec/priorityClassName 110 | kind: Rollout 111 | - path: spec/templates/template/spec/priorityClassName 112 | kind: Experiment 113 | - path: spec/metrics/provider/job/spec/template/spec/priorityClassName 114 | kind: AnalysisTemplate 115 | 116 | # The name references below are unique to Rollouts and not applicable to Deployment 117 | - kind: Service 118 | version: v1 119 | fieldSpecs: 120 | - path: spec/strategy/blueGreen/activeService 121 | kind: Rollout 122 | - path: spec/strategy/blueGreen/previewService 123 | kind: Rollout 124 | - path: spec/strategy/canary/canaryService 125 | kind: Rollout 126 | - path: spec/strategy/canary/stableService 127 | kind: Rollout 128 | - path: spec/strategy/canary/trafficRouting/alb/rootService 129 | kind: Rollout 130 | - kind: VirtualService 131 | group: networking.istio.io 132 | fieldSpecs: 133 | - path: spec/strategy/canary/trafficRouting/istio/virtualService/name 134 | kind: Rollout 135 | - kind: DestinationRule 136 | group: networking.istio.io 137 | fieldSpecs: 138 | - path: spec/strategy/canary/trafficRouting/istio/destinationRule/name 139 | kind: Rollout 140 | - kind: Ingress 141 | group: networking.k8s.io 142 | fieldSpecs: 143 | - path: spec/strategy/canary/trafficRouting/alb/ingress 144 | kind: Rollout 145 | - path: spec/strategy/canary/trafficRouting/nginx/stableIngress 146 | kind: Rollout 147 | - kind: Ingress 148 | group: extensions 149 | fieldSpecs: 150 | - path: spec/strategy/canary/trafficRouting/alb/ingress 151 | kind: Rollout 152 | - path: spec/strategy/canary/trafficRouting/nginx/stableIngress 153 | kind: Rollout 154 | - kind: AnalysisTemplate 155 | group: argoproj.io 156 | fieldSpecs: 157 | - path: spec/strategy/blueGreen/prePromotionAnalysis/templates/templateName 158 | kind: Rollout 159 | - path: spec/strategy/blueGreen/postPromotionAnalysis/templates/templateName 160 | kind: Rollout 161 | - path: spec/strategy/canary/analysis/templates/templateName 162 | kind: Rollout 163 | - path: spec/strategy/canary/steps/analysis/templates/templateName 164 | kind: Rollout 165 | - path: spec/strategy/canary/steps/experiment/analyses/templateName 166 | kind: Rollout 167 | - path: spec/analyses/templateName 168 | kind: Experiment 169 | - kind: Rollout 170 | fieldSpecs: 171 | - path: spec/scaleTargetRef/name 172 | kind: HorizontalPodAutoscaler 173 | - kind: Deployment 174 | version: v1 175 | group: apps 176 | fieldSpecs: 177 | - path: spec/workloadRef/name 178 | kind: Rollout 179 | - kind: Mapping 180 | group: getambassador.io 181 | fieldSpecs: 182 | - path: spec/strategy/canary/trafficRouting/ambassador/mappings 183 | kind: Rollout 184 | 185 | # https://github.com/kubernetes-sigs/kustomize/blob/master/api/konfig/builtinpluginconsts/commonlabels.go 186 | commonLabels: 187 | - path: spec/selector/matchLabels 188 | create: true 189 | kind: Rollout 190 | - path: spec/template/metadata/labels 191 | create: true 192 | kind: Rollout 193 | - path: spec/template/spec/affinity/podAffinity/preferredDuringSchedulingIgnoredDuringExecution/podAffinityTerm/labelSelector/matchLabels 194 | create: false 195 | kind: Rollout 196 | - path: spec/template/spec/affinity/podAffinity/requiredDuringSchedulingIgnoredDuringExecution/labelSelector/matchLabels 197 | create: false 198 | kind: Rollout 199 | - path: spec/template/spec/affinity/podAntiAffinity/preferredDuringSchedulingIgnoredDuringExecution/podAffinityTerm/labelSelector/matchLabels 200 | create: false 201 | kind: Rollout 202 | - path: spec/template/spec/affinity/podAntiAffinity/requiredDuringSchedulingIgnoredDuringExecution/labelSelector/matchLabels 203 | create: false 204 | kind: Rollout 205 | 206 | # https://github.com/kubernetes-sigs/kustomize/blob/master/api/konfig/builtinpluginconsts/commonannotations.go 207 | commonAnnotations: 208 | - path: spec/template/metadata/annotations 209 | create: true 210 | kind: Rollout 211 | 212 | # https://github.com/kubernetes-sigs/kustomize/blob/master/api/konfig/builtinpluginconsts/varreference.go 213 | varReference: 214 | - path: spec/template/spec/containers/args 215 | kind: Rollout 216 | - path: spec/template/spec/containers/command 217 | kind: Rollout 218 | - path: spec/template/spec/containers/env/value 219 | kind: Rollout 220 | - path: spec/template/spec/containers/volumeMounts/mountPath 221 | kind: Rollout 222 | - path: spec/template/spec/initContainers/args 223 | kind: Rollout 224 | - path: spec/template/spec/initContainers/command 225 | kind: Rollout 226 | - path: spec/template/spec/initContainers/env/value 227 | kind: Rollout 228 | - path: spec/template/spec/initContainers/volumeMounts/mountPath 229 | kind: Rollout 230 | - path: spec/templates/template/spec/containers/args 231 | kind: Experiment 232 | - path: spec/templates/template/spec/containers/command 233 | kind: Experiment 234 | - path: spec/templates/template/spec/containers/env/value 235 | kind: Experiment 236 | - path: spec/templates/template/spec/containers/volumeMounts/mountPath 237 | kind: Experiment 238 | - path: spec/templates/template/spec/initContainers/args 239 | kind: Experiment 240 | - path: spec/templates/template/spec/initContainers/command 241 | kind: Experiment 242 | - path: spec/templates/template/spec/initContainers/env/value 243 | kind: Experiment 244 | - path: spec/templates/template/spec/initContainers/volumeMounts/mountPath 245 | kind: Experiment 246 | - path: spec/metrics/provider/job/spec/template/spec/containers/args 247 | kind: AnalysisTemplate 248 | - path: spec/metrics/provider/job/spec/template/spec/containers/command 249 | kind: AnalysisTemplate 250 | - path: spec/metrics/provider/job/spec/template/spec/containers/env/value 251 | kind: AnalysisTemplate 252 | - path: spec/metrics/provider/job/spec/template/spec/containers/volumeMounts/mountPath 253 | kind: AnalysisTemplate 254 | - path: spec/metrics/provider/job/spec/template/spec/initContainers/args 255 | kind: AnalysisTemplate 256 | - path: spec/metrics/provider/job/spec/template/spec/initContainers/command 257 | kind: AnalysisTemplate 258 | - path: spec/metrics/provider/job/spec/template/spec/initContainers/env/value 259 | kind: AnalysisTemplate 260 | - path: spec/metrics/provider/job/spec/template/spec/initContainers/volumeMounts/mountPath 261 | kind: AnalysisTemplate 262 | - path: spec/metrics/provider/job/spec/template/spec/volumes/nfs/server 263 | kind: AnalysisTemplate 264 | 265 | # https://github.com/kubernetes-sigs/kustomize/blob/master/api/konfig/builtinpluginconsts/replicas.go 266 | replicas: 267 | - path: spec/replicas 268 | create: true 269 | kind: Rollout 270 | 271 | # BEGIN PATCH 272 | templateLabels: 273 | - path: spec/template/metadata/labels 274 | create: true 275 | kind: Rollout 276 | --------------------------------------------------------------------------------