├── .github ├── dependabot.yml └── workflows │ ├── cleanup.yml │ ├── draft-release.yml │ ├── integration.yml │ ├── publish.yml │ ├── release.yml │ └── unit.yml ├── .gitignore ├── .prettierrc.js ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE ├── README.md ├── action.yml ├── dist └── main │ └── index.js ├── eslint.config.mjs ├── example-app ├── .dockerignore ├── Dockerfile ├── index.js ├── package-lock.json └── package.json ├── package-lock.json ├── package.json ├── src ├── main.ts └── output-parser.ts ├── tests ├── e2e.test.ts ├── fixtures │ ├── env_vars.txt │ ├── job.yaml │ └── service.yaml └── unit │ ├── main.test.ts │ └── output-parser.test.ts └── tsconfig.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | rebase-strategy: 'disabled' 6 | schedule: 7 | interval: 'daily' 8 | commit-message: 9 | prefix: 'security: ' 10 | open-pull-requests-limit: 0 # only check security updates 11 | -------------------------------------------------------------------------------- /.github/workflows/cleanup.yml: -------------------------------------------------------------------------------- 1 | name: 'Cleanup' 2 | 3 | on: 4 | schedule: 5 | - cron: '0 */6 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: 'read' 10 | id-token: 'write' 11 | 12 | jobs: 13 | cleanup: 14 | runs-on: 'ubuntu-latest' 15 | 16 | steps: 17 | - uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4 18 | 19 | - uses: 'google-github-actions/auth@v2' # ratchet:exclude 20 | with: 21 | workload_identity_provider: '${{ vars.WIF_PROVIDER_NAME }}' 22 | service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' 23 | 24 | - uses: 'google-github-actions/setup-gcloud@v2' # ratchet:exclude 25 | with: 26 | version: 'latest' 27 | 28 | - name: 'Delete services' 29 | run: |- 30 | gcloud config set core/project "${{ vars.PROJECT_ID }}" 31 | gcloud config set run/region "us-central1" 32 | 33 | # List and delete all services that were deployed 30 minutes ago or 34 | # earlier. The date math here is a little weird, but we're looking for 35 | # deployments "earlier than" 30 minutes ago, so it's less than since 36 | # time increases. 37 | (IFS=$'\n'; for NAME in $(gcloud run services list --format="value(name)" --filter="metadata.creationTimestamp < '-pt30m'"); do 38 | echo "Deleting ${NAME}..." 39 | gcloud run services delete ${NAME} --quiet --async 40 | done) 41 | -------------------------------------------------------------------------------- /.github/workflows/draft-release.yml: -------------------------------------------------------------------------------- 1 | name: 'Draft release' 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version_strategy: 7 | description: 'Version strategy: The strategy to used to update the version based on semantic versioning (more info at https://semver.org/).' 8 | required: true 9 | default: 'patch' 10 | type: 'choice' 11 | options: 12 | - 'major' 13 | - 'minor' 14 | - 'patch' 15 | 16 | jobs: 17 | draft-release: 18 | uses: 'google-github-actions/.github/.github/workflows/draft-release.yml@v3' # ratchet:exclude 19 | with: 20 | version_strategy: '${{ github.event.inputs.version_strategy }}' 21 | secrets: 22 | ACTIONS_BOT_TOKEN: '${{ secrets.ACTIONS_BOT_TOKEN }}' 23 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: 'Integration' 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | - 'release/**/*' 8 | pull_request: 9 | branches: 10 | - 'main' 11 | - 'release/**/*' 12 | workflow_dispatch: 13 | 14 | concurrency: 15 | group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' 16 | cancel-in-progress: true 17 | 18 | permissions: 19 | contents: 'read' 20 | id-token: 'write' 21 | 22 | jobs: 23 | deploy: 24 | runs-on: 'ubuntu-latest' 25 | 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | include: 30 | - name: 'image' 31 | image: 'gcr.io/cloudrun/hello' 32 | - name: 'source' 33 | source: 'example-app' 34 | 35 | name: 'from_${{ matrix.name }}' 36 | 37 | steps: 38 | - uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4 39 | 40 | - name: 'Compute service name' 41 | run: |- 42 | echo "SERVICE_NAME=${GITHUB_JOB}-${{ matrix.name }}-${GITHUB_SHA::7}-${GITHUB_RUN_NUMBER}" >> ${GITHUB_ENV} 43 | 44 | - uses: 'actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a' # ratchet:actions/setup-node@v4 45 | with: 46 | node-version: '20.12.x' # https://github.com/nodejs/node/issues/53033 47 | 48 | - run: 'npm ci && npm run build' 49 | 50 | - uses: 'google-github-actions/auth@v2' # ratchet:exclude 51 | with: 52 | workload_identity_provider: '${{ vars.WIF_PROVIDER_NAME }}' 53 | service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' 54 | 55 | - id: 'deploy-cloudrun' 56 | name: 'Deploy' 57 | uses: './' 58 | with: 59 | image: '${{ matrix.image }}' 60 | source: '${{ matrix.source }}' 61 | service: '${{ env.SERVICE_NAME }}' 62 | env_vars: |- 63 | FOO=bar 64 | ZIP=zap\,with|separators\,and&stuff 65 | env_vars_file: './tests/fixtures/env_vars.txt' 66 | secrets: |- 67 | MY_SECRET=${{ vars.SECRET_NAME }}:latest 68 | MY_SECOND_SECRET=${{ vars.SECRET_NAME }}:1 69 | labels: |- 70 | label1=value1 71 | label2=value2 72 | skip_default_labels: true 73 | flags: '--cpu=2 --concurrency=20' 74 | 75 | - name: 'Run initial deploy tests' 76 | run: 'npm run e2e-tests' 77 | env: 78 | PROJECT_ID: ${{ vars.PROJECT_ID }} 79 | SERVICE: '${{ env.SERVICE_NAME }}' 80 | ENV: |- 81 | { 82 | "FOO": "bar", 83 | "ZIP": "zap,with|separators,and&stuff", 84 | "TEXT_FOO": "bar", 85 | "TEXT_ZIP": "zap,with|separators,and&stuff" 86 | } 87 | SECRET_ENV: |- 88 | { 89 | "MY_SECRET": "${{ vars.SECRET_NAME }}:latest", 90 | "MY_SECOND_SECRET": "${{ vars.SECRET_NAME }}:1" 91 | } 92 | PARAMS: |- 93 | { 94 | "cpu": "2", 95 | "containerConcurrency": "20" 96 | } 97 | LABELS: |- 98 | { 99 | "label1": "value1", 100 | "label2": "value2" 101 | } 102 | 103 | - id: 'deploy-cloudrun-again' 104 | name: 'Deploy again' 105 | uses: './' 106 | with: 107 | image: '${{ matrix.image }}' 108 | source: '${{ matrix.source }}' 109 | service: '${{ env.SERVICE_NAME }}' 110 | env_vars: |- 111 | ABC=123 112 | DEF=456 113 | env_vars_update_strategy: 'overwrite' 114 | secrets: /api/secrets/my-secret=${{ vars.SECRET_NAME }}:latest 115 | secrets_update_strategy: 'overwrite' 116 | to_revision: 'LATEST=100' 117 | 118 | - name: 'Run re-deploy tests' 119 | run: 'npm run e2e-tests' 120 | env: 121 | PROJECT_ID: ${{ vars.PROJECT_ID }} 122 | SERVICE: '${{ env.SERVICE_NAME }}' 123 | ENV: |- 124 | { 125 | "ABC": "123", 126 | "DEF": "456" 127 | } 128 | SECRET_ENV: |- 129 | {} 130 | SECRET_VOLUMES: |- 131 | { 132 | "/api/secrets/my-secret": "${{ vars.SECRET_NAME }}:latest" 133 | } 134 | PARAMS: |- 135 | { 136 | "cpu": "2", 137 | "containerConcurrency": "20" 138 | } 139 | LABELS: |- 140 | { 141 | "label1": "value1", 142 | "label2": "value2", 143 | "commit-sha": "${{ github.sha }}", 144 | "managed-by": "github-actions" 145 | } 146 | REVISION_COUNT: 2 147 | 148 | metadata: 149 | runs-on: 'ubuntu-latest' 150 | 151 | steps: 152 | - uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4 153 | 154 | - name: 'Compute service name' 155 | run: |- 156 | echo "SERVICE_NAME=${GITHUB_JOB}-metadata-${GITHUB_SHA::7}-${GITHUB_RUN_NUMBER}" >> ${GITHUB_ENV} 157 | 158 | - name: 'Set service name in metadata YAML' 159 | run: |- 160 | sed -i "s/run-full-yaml/${{ env.SERVICE_NAME }}/" ./tests/fixtures/service.yaml 161 | 162 | - uses: 'actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a' # ratchet:actions/setup-node@v4 163 | with: 164 | node-version: '20.12.x' # https://github.com/nodejs/node/issues/53033 165 | 166 | - run: 'npm ci && npm run build' 167 | 168 | - uses: 'google-github-actions/auth@v2' # ratchet:exclude 169 | with: 170 | workload_identity_provider: '${{ vars.WIF_PROVIDER_NAME }}' 171 | service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' 172 | 173 | - id: 'deploy-cloudrun' 174 | name: 'Deploy' 175 | uses: './' 176 | with: 177 | metadata: './tests/fixtures/service.yaml' 178 | 179 | - name: 'Run initial deploy tests' 180 | run: 'npm run e2e-tests' 181 | env: 182 | PROJECT_ID: '${{ vars.PROJECT_ID }}' 183 | SERVICE: '${{ env.SERVICE_NAME }}' 184 | PARAMS: |- 185 | { 186 | "cpu": "2", 187 | "memory": "1Gi", 188 | "containerConcurrency": "20" 189 | } 190 | ANNOTATIONS: |- 191 | { 192 | "run.googleapis.com/cloudsql-instances": "test-project:us-central1:my-test-instance" 193 | } 194 | LABELS: |- 195 | { 196 | "test_label": "test_value" 197 | } 198 | 199 | - id: 'deploy-cloudrun-again' 200 | name: 'Deploy again' 201 | uses: './' 202 | with: 203 | image: 'gcr.io/cloudrun/hello' 204 | service: '${{ env.SERVICE_NAME }}' 205 | to_revision: 'LATEST=100' 206 | 207 | - name: 'Run re-deploy tests' 208 | run: 'npm run e2e-tests' # Check that config isn't overwritten 209 | env: 210 | PROJECT_ID: '${{ vars.PROJECT_ID }}' 211 | SERVICE: '${{ env.SERVICE_NAME }}' 212 | PARAMS: |- 213 | { 214 | "cpu": "2", 215 | "memory": "1Gi", 216 | "containerConcurrency": "20" 217 | } 218 | ANNOTATIONS: |- 219 | { 220 | "run.googleapis.com/cloudsql-instances": "test-project:us-central1:my-test-instance" 221 | } 222 | REVISION_COUNT: 2 223 | 224 | jobs: 225 | runs-on: 'ubuntu-latest' 226 | 227 | steps: 228 | - uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4 229 | 230 | - name: 'Compute job name' 231 | run: |- 232 | echo "JOB_NAME=${GITHUB_JOB}-job-${GITHUB_SHA::7}-${GITHUB_RUN_NUMBER}" >> ${GITHUB_ENV} 233 | 234 | - uses: 'actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a' # ratchet:actions/setup-node@v4 235 | with: 236 | node-version: '20.12.x' # https://github.com/nodejs/node/issues/53033 237 | 238 | - run: 'npm ci && npm run build' 239 | 240 | - uses: 'google-github-actions/auth@v2' # ratchet:exclude 241 | with: 242 | workload_identity_provider: '${{ vars.WIF_PROVIDER_NAME }}' 243 | service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' 244 | 245 | - id: 'deploy-cloudrun' 246 | name: 'Deploy' 247 | uses: './' 248 | with: 249 | image: 'gcr.io/cloudrun/hello' 250 | job: '${{ env.JOB_NAME }}' 251 | env_vars: |- 252 | FOO=bar 253 | ZIP=zap\,with|separators\,and&stuff 254 | env_vars_file: './tests/fixtures/env_vars.txt' 255 | secrets: |- 256 | MY_SECRET=${{ vars.SECRET_NAME }}:latest 257 | MY_SECOND_SECRET=${{ vars.SECRET_NAME }}:1 258 | labels: |- 259 | label1=value1 260 | label2=value2 261 | skip_default_labels: true 262 | flags: '--cpu=2' 263 | 264 | - name: 'Run initial deploy tests' 265 | run: 'npm run e2e-tests' 266 | env: 267 | PROJECT_ID: ${{ vars.PROJECT_ID }} 268 | JOB: '${{ env.JOB_NAME }}' 269 | ENV: |- 270 | { 271 | "FOO": "bar", 272 | "ZIP": "zap,with|separators,and&stuff", 273 | "TEXT_FOO": "bar", 274 | "TEXT_ZIP": "zap,with|separators,and&stuff" 275 | } 276 | SECRET_ENV: |- 277 | { 278 | "MY_SECRET": "${{ vars.SECRET_NAME }}:latest", 279 | "MY_SECOND_SECRET": "${{ vars.SECRET_NAME }}:1" 280 | } 281 | LABELS: |- 282 | { 283 | "label1": "value1", 284 | "label2": "value2" 285 | } 286 | 287 | - id: 'deploy-cloudrun-again' 288 | name: 'Deploy again' 289 | uses: './' 290 | with: 291 | image: 'gcr.io/cloudrun/hello' 292 | job: '${{ env.JOB_NAME }}' 293 | env_vars: |- 294 | ABC=123 295 | DEF=456 296 | env_vars_update_strategy: 'overwrite' 297 | secrets: /api/secrets/my-secret=${{ vars.SECRET_NAME }}:latest 298 | 299 | - name: 'Run re-deploy tests' 300 | run: 'npm run e2e-tests' 301 | env: 302 | PROJECT_ID: ${{ vars.PROJECT_ID }} 303 | JOB: '${{ env.JOB_NAME }}' 304 | ENV: |- 305 | { 306 | "ABC": "123", 307 | "DEF": "456" 308 | } 309 | SECRET_VOLUMES: |- 310 | { 311 | "/api/secrets/my-secret": "${{ vars.SECRET_NAME }}:latest" 312 | } 313 | LABELS: |- 314 | { 315 | "label1": "value1", 316 | "label2": "value2", 317 | "commit-sha": "${{ github.sha }}", 318 | "managed-by": "github-actions" 319 | } 320 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish immutable action version' 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: 7 | - 'published' 8 | 9 | jobs: 10 | publish: 11 | runs-on: 'ubuntu-latest' 12 | permissions: 13 | contents: 'read' 14 | id-token: 'write' 15 | packages: 'write' 16 | 17 | steps: 18 | - name: 'Checkout' 19 | uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4 20 | 21 | - name: 'Publish' 22 | id: 'publish' 23 | uses: 'actions/publish-immutable-action@4bc8754ffc40f27910afb20287dbbbb675a4e978' # ratchet:actions/publish-immutable-action@v0.0.4 24 | with: 25 | github-token: '${{ secrets.GITHUB_TOKEN }}' 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 'Release' 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | - 'release/**/*' 8 | 9 | jobs: 10 | release: 11 | uses: 'google-github-actions/.github/.github/workflows/release.yml@v3' # ratchet:exclude 12 | secrets: 13 | ACTIONS_BOT_TOKEN: '${{ secrets.ACTIONS_BOT_TOKEN }}' 14 | -------------------------------------------------------------------------------- /.github/workflows/unit.yml: -------------------------------------------------------------------------------- 1 | name: 'Unit' 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | - 'release/**/*' 8 | pull_request: 9 | branches: 10 | - 'main' 11 | - 'release/**/*' 12 | workflow_dispatch: 13 | 14 | concurrency: 15 | group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | unit: 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | os: 24 | - 'ubuntu-latest' 25 | - 'windows-latest' 26 | - 'macos-latest' 27 | runs-on: '${{ matrix.os }}' 28 | 29 | steps: 30 | - uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4 31 | 32 | - uses: 'actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a' # ratchet:actions/setup-node@v4 33 | with: 34 | node-version: '20.x' 35 | 36 | - name: 'npm build' 37 | run: 'npm ci && npm run build' 38 | 39 | - name: 'npm lint' 40 | # There's no need to run the linter for each operating system, since it 41 | # will find the same thing 3x and clog up the PR review. 42 | if: ${{ matrix.os == 'ubuntu-latest' }} 43 | run: 'npm run lint' 44 | 45 | - name: 'npm test' 46 | run: 'npm run test' 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | runner/ 3 | 4 | # Rest of the file pulled from https://github.com/github/gitignore/blob/main/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 | # TypeScript v1 declaration files 30 | typings/ 31 | 32 | # TypeScript cache 33 | *.tsbuildinfo 34 | 35 | # Optional npm cache directory 36 | .npm 37 | 38 | # Optional eslint cache 39 | .eslintcache 40 | 41 | # Optional REPL history 42 | .node_repl_history 43 | 44 | # Output of 'npm pack' 45 | *.tgz 46 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSpacing: true, 4 | endOfLine: 'auto', 5 | jsxSingleQuote: true, 6 | printWidth: 100, 7 | quoteProps: 'consistent', 8 | semi: true, 9 | singleQuote: true, 10 | tabWidth: 2, 11 | trailingComma: 'all', 12 | useTabs: false, 13 | }; 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Changelogs for each release are located on the [releases page](https://github.com/google-github-actions/deploy-cloudrun/releases). 4 | 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @google-github-actions/maintainers 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "{}" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright {yyyy} {name of copyright owner} 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # deploy-cloudrun 2 | 3 | The `deploy-cloudrun` GitHub Action deploys to Google [Cloud Run][cloud-run]. It 4 | can deploy a container image or from source, and the resulting service URL is 5 | available as a GitHub Actions output for use in future steps. 6 | 7 | **This is not an officially supported Google product, and it is not covered by a 8 | Google Cloud support contract. To report bugs or request features in a Google 9 | Cloud product, please contact [Google Cloud 10 | support](https://cloud.google.com/support).** 11 | 12 | 13 | ## Prerequisites 14 | 15 | - This action requires Google Cloud credentials that are authorized to access 16 | the secrets being requested. See [Authorization](#authorization) for more 17 | information. 18 | 19 | - This action runs using Node 20. If you are using self-hosted GitHub Actions 20 | runners, you must use a [runner 21 | version](https://github.com/actions/virtual-environments) that supports this 22 | version or newer. 23 | 24 | 25 | ## Usage 26 | 27 | ```yaml 28 | jobs: 29 | job_id: 30 | # ... 31 | 32 | permissions: 33 | contents: 'read' 34 | id-token: 'write' 35 | 36 | steps: 37 | - uses: 'actions/checkout@v4' 38 | 39 | - uses: 'google-github-actions/auth@v2' 40 | with: 41 | workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider' 42 | service_account: 'my-service-account@my-project.iam.gserviceaccount.com' 43 | 44 | - id: 'deploy' 45 | uses: 'google-github-actions/deploy-cloudrun@v2' 46 | with: 47 | service: 'hello-cloud-run' 48 | image: 'gcr.io/cloudrun/hello' 49 | 50 | - name: 'Use output' 51 | run: 'curl "${{ steps.deploy.outputs.url }}"' 52 | ``` 53 | 54 | ## Inputs 55 | 56 | 57 | 58 | - service: _(Optional)_ ID of the service or fully-qualified identifier of the service. This is 59 | required unless providing `metadata` or `job`. 60 | 61 | - job: _(Optional)_ ID of the job or fully-qualified identifier of the job. This is required 62 | unless providing `metadata` or `service`. 63 | 64 | - metadata: _(Optional)_ YAML service description for the Cloud Run service. This is required 65 | unless providing `service` or `job`. 66 | 67 | - image: _(Optional)_ (Required, unless providing `metadata` or `source`) Fully-qualified name 68 | of the container image to deploy. For example: 69 | 70 | gcr.io/cloudrun/hello:latest 71 | 72 | or 73 | 74 | us-docker.pkg.dev/my-project/my-container/image:1.2.3 75 | 76 | - source: _(Optional)_ (Required, unless providing `metadata`, `image`, or `job`) Path to source 77 | to deploy. If specified, this will deploy the Cloud Run service from the 78 | code specified at the given source directory. 79 | 80 | Learn more about the required permissions in [Deploying from source 81 | code](https://cloud.google.com/run/docs/deploying-source-code). 82 | 83 | - suffix: _(Optional)_ String suffix to append to the revision name. Revision names always start 84 | with the service name automatically. For example, specifying `v1` for a 85 | service named `helloworld`, would lead to a revision named 86 | `helloworld-v1`. This option only applies to services. 87 | 88 | - env_vars: _(Optional)_ List of environment variables that should be set in the environment. 89 | These are comma-separated or newline-separated `KEY=VALUE`. Keys or values 90 | that contain separators must be escaped with a backslash (e.g. `\,` or 91 | `\\n`) unless quoted. Any leading or trailing whitespace is trimmed unless 92 | values are quoted. 93 | 94 | env_vars: |- 95 | FRUIT=apple 96 | SENTENCE=" this will retain leading and trailing spaces " 97 | 98 | This value will only be set if the input is a non-empty value. If a 99 | non-empty value is given, the field values will be overwritten (not 100 | merged). To remove all values, set the value to the literal string `{}`. 101 | 102 | If both `env_vars` and `env_vars_file` are specified, the keys in 103 | `env_vars` will take precedence over the keys in `env_vars_file`. 104 | 105 | - env_vars_file: _(Optional)_ Path to a file on disk, relative to the workspace, that defines 106 | environment variables. The file can be newline-separated KEY=VALUE pairs, 107 | JSON, or YAML format. If both `env_vars` and `env_vars_file` are 108 | specified, the keys in env_vars will take precedence over the keys in 109 | env_vars_file. 110 | 111 | NAME=person 112 | EMAILS=foo@bar.com\,zip@zap.com 113 | 114 | When specified as KEY=VALUE pairs, the same escaping rules apply as 115 | described in `env_vars`. You do not have to escape YAML or JSON. 116 | 117 | If both `env_vars` and `env_vars_file` are specified, the keys in 118 | `env_vars` will take precedence over the keys in `env_vars_file`. 119 | 120 | **⚠️ DEPRECATION NOTICE:** This input is deprecated and will be removed in 121 | the next major version release. 122 | 123 | - env_vars_update_strategy: _(Required, default: `merge`)_ Controls how the environment variables are set on the Cloud Run service. 124 | If set to "merge", then the environment variables are _merged_ with any 125 | upstream values. If set to "overwrite", then all environment variables on 126 | the Cloud Run service will be replaced with exactly the values given by 127 | the GitHub Action (making it authoritative). 128 | 129 | - secrets: _(Optional)_ List of KEY=VALUE pairs to use as secrets. These are comma-separated or 130 | newline-separated `KEY=VALUE`. Keys or values that contain separators must 131 | be escaped with a backslash (e.g. `\,` or `\\n`) unless quoted. Any 132 | leading or trailing whitespace is trimmed unless values are quoted. 133 | 134 | These can either be injected as environment variables or mounted as 135 | volumes. Keys starting with a forward slash '/' are mount paths. All other 136 | keys correspond to environment variables: 137 | 138 | with: 139 | secrets: |- 140 | # As an environment variable: 141 | KEY1=secret-key-1:latest 142 | 143 | # As a volume mount: 144 | /secrets/api/key=secret-key-2:latest 145 | 146 | This value will only be set if the input is a non-empty value. If a 147 | non-empty value is given, the field values will be overwritten (not 148 | merged). To remove all values, set the value to the literal string `{}`. 149 | 150 | - secrets_update_strategy: _(Required, default: `merge`)_ Controls how the secrets are set on the Cloud Run service. If set to 151 | `merge`, then the secrets are merged with any upstream values. If set to 152 | `overwrite`, then all secrets on the Cloud Run service will be replaced 153 | with exactly the values given by the GitHub Action (making it 154 | authoritative). 155 | 156 | - labels: _(Optional)_ List of labels that should be set on the function. These are 157 | comma-separated or newline-separated `KEY=VALUE`. Keys or values that 158 | contain separators must be escaped with a backslash (e.g. `\,` or `\\n`) 159 | unless quoted. Any leading or trailing whitespace is trimmed unless values 160 | are quoted. 161 | 162 | labels: |- 163 | labela=my-label 164 | labelb=my-other-label 165 | 166 | This value will only be set if the input is a non-empty value. If a 167 | non-empty value is given, the field values will be overwritten (not 168 | merged). To remove all values, set the value to the literal string `{}`. 169 | 170 | Google Cloud restricts the allowed values and length for labels. Please 171 | see the Google Cloud documentation for labels for more information. 172 | 173 | - skip_default_labels: _(Optional, default: `false`)_ Skip applying the special annotation labels that indicate the deployment 174 | came from GitHub Actions. The GitHub Action will automatically apply the 175 | following labels which Cloud Run uses to enhance the user experience: 176 | 177 | managed-by: github-actions 178 | commit-sha: 179 | 180 | Setting this to `true` will skip adding these special labels. 181 | 182 | - tag: _(Optional)_ Traffic tag to assign to the newly-created revision. This option only 183 | applies to services. 184 | 185 | - timeout: _(Optional)_ Maximum request execution time, specified as a duration like "10m5s" for 186 | ten minutes and 5 seconds. 187 | 188 | - flags: _(Optional)_ Space separate list of additional Cloud Run flags to pass to the deploy 189 | command. This can be used to apply advanced features that are not exposed 190 | via this GitHub Action. For Cloud Run services, this command will be 191 | `gcloud run deploy`. For Cloud Run jobs, this command will be `gcloud jobs 192 | deploy`. 193 | 194 | with: 195 | flags: '--add-cloudsql-instances=...' 196 | 197 | Flags that include other flags must quote the _entire_ outer flag value. For 198 | example, to pass `--args=-X=123`: 199 | 200 | with: 201 | flags: '--add-cloudsql-instances=... "--args=-X=123"' 202 | 203 | See the [complete list of 204 | flags](https://cloud.google.com/sdk/gcloud/reference/run/deploy#FLAGS) for 205 | more information. 206 | 207 | Please note, this GitHub Action does not parse or validate the flags. You 208 | are responsible for making sure the flags are available on the gcloud 209 | version and subcommand. 210 | 211 | - no_traffic: _(Optional, default: `false`)_ If true, the newly deployed revision will not receive traffic. This option 212 | only applies to services. 213 | 214 | - revision_traffic: _(Optional)_ Comma-separated list of revision traffic assignments. 215 | 216 | with: 217 | revision_traffic: 'my-revision=10' # percentage 218 | 219 | To update traffic to the latest revision, use the special tag "LATEST": 220 | 221 | with: 222 | revision_traffic: 'LATEST=100' 223 | 224 | This is mutually-exclusive with `tag_traffic`. This option only applies 225 | to services. 226 | 227 | - tag_traffic: _(Optional)_ Comma-separated list of tag traffic assignments. 228 | 229 | with: 230 | tag_traffic: 'my-tag=10' # percentage 231 | 232 | This is mutually-exclusive with `revision_traffic`. This option only 233 | applies to services. 234 | 235 | - update_traffic_flags: _(Optional)_ Space separate list of additional Cloud Run flags to pass to the `gcloud 236 | run services update-traffic` command. This can be used to apply advanced 237 | features that are not exposed via this GitHub Action. This flag only 238 | applies when `revision_traffic` or `tag_traffic` is set. 239 | 240 | with: 241 | traffic_flags: '--set-tags=...' 242 | 243 | Flags that include other flags must quote the _entire_ outer flag value. For 244 | example, to pass `--args=-X=123`: 245 | 246 | with: 247 | flags: '--set-tags=... "--args=-X=123"' 248 | 249 | See the [complete list of 250 | flags](https://cloud.google.com/sdk/gcloud/reference/run/services/update#FLAGS) 251 | for more information. 252 | 253 | Please note, this GitHub Action does not parse or validate the flags. You 254 | are responsible for making sure the flags are available on the gcloud 255 | version and subcommand. 256 | 257 | - project_id: _(Optional)_ ID of the Google Cloud project in which to deploy the service. 258 | 259 | - region: _(Optional, default: `us-central1`)_ Region in which the Cloud Run services are deployed. 260 | 261 | - gcloud_version: _(Optional)_ Version of the Cloud SDK to install. If unspecified or set to "latest", 262 | the latest available gcloud SDK version for the target platform will be 263 | installed. Example: "290.0.1". 264 | 265 | - gcloud_component: _(Optional)_ Version of the Cloud SDK components to install and use. 266 | 267 | 268 | 269 | 270 | ### Custom metadata YAML 271 | 272 | For advanced use cases, you can define a custom Cloud Run metadata file. This is 273 | a YAML description of the Cloud Run service or job. This allows you to customize your 274 | service configuration, such as [memory 275 | limits](https://cloud.google.com/run/docs/configuring/memory-limits), [CPU 276 | allocation](https://cloud.google.com/run/docs/configuring/cpu), [max 277 | instances](https://cloud.google.com/run/docs/configuring/max-instances), and 278 | [more](https://cloud.google.com/sdk/gcloud/reference/run/deploy#OPTIONAL-FLAGS). 279 | 280 | **⚠️ When using a custom metadata YAML file, all other inputs are ignored!** 281 | 282 | - `metadata`: (Optional) The path to a Cloud Run service or job metadata file. 283 | 284 | To [deploying a new service](https://cloud.google.com/run/docs/deploying#yaml) 285 | to create a new YAML service definition: 286 | 287 | ```yaml 288 | apiVersion: serving.knative.dev/v1 289 | kind: Service 290 | metadata: 291 | name: SERVICE 292 | spec: 293 | template: 294 | spec: 295 | containers: 296 | - image: IMAGE 297 | ``` 298 | 299 | To update a revision or to [deploy a new revision of an existing service](https://cloud.google.com/run/docs/deploying#yaml_1), download and modify the YAML service definition: 300 | 301 | ```shell 302 | gcloud run services describe SERVICE --format yaml > service.yaml 303 | ``` 304 | 305 | ## Allowing unauthenticated requests 306 | 307 | A Cloud Run product recommendation is that CI/CD systems not set or change 308 | settings for allowing unauthenticated invocations. New deployments are 309 | automatically private services, while deploying a revision of a public 310 | (unauthenticated) service will preserve the IAM setting of public 311 | (unauthenticated). For more information, see [Controlling access on an individual service](https://cloud.google.com/run/docs/securing/managing-access). 312 | 313 | ## Outputs 314 | 315 | 316 | 317 | - `url`: The URL of the Cloud Run service. 318 | 319 | 320 | 321 | 322 | 323 | ## Authorization 324 | 325 | There are a few ways to authenticate this action. The caller must have 326 | permissions to access the secrets being requested. 327 | 328 | You will need to authenticate to Google Cloud as a service account with the 329 | following roles: 330 | 331 | - Cloud Run Admin (`roles/run.admin`): 332 | - Can create, update, and delete services. 333 | - Can get and set IAM policies. 334 | 335 | This service account needs to be a member of the `Compute Engine default service account`, 336 | `(PROJECT_NUMBER-compute@developer.gserviceaccount.com)`, with role 337 | `Service Account User`. To grant a user permissions for a service account, use 338 | one of the methods found in [Configuring Ownership and access to a service account](https://cloud.google.com/iam/docs/granting-roles-to-service-accounts#granting_access_to_a_user_for_a_service_account). 339 | 340 | 341 | ### Via google-github-actions/auth 342 | 343 | Use [google-github-actions/auth](https://github.com/google-github-actions/auth) 344 | to authenticate the action. You can use [Workload Identity Federation][wif] or 345 | traditional [Service Account Key JSON][sa] authentication. 346 | 347 | ```yaml 348 | jobs: 349 | job_id: 350 | permissions: 351 | contents: 'read' 352 | id-token: 'write' 353 | 354 | steps: 355 | 356 | # ... 357 | 358 | - uses: 'google-github-actions/auth@v2' 359 | with: 360 | workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider' 361 | service_account: 'my-service-account@my-project.iam.gserviceaccount.com' 362 | 363 | - uses: 'google-github-actions/deploy-cloudrun@v2' 364 | with: 365 | image: 'gcr.io/cloudrun/hello' 366 | service: 'hello-cloud-run' 367 | ``` 368 | 369 | ### Via Application Default Credentials 370 | 371 | If you are hosting your own runners, **and** those runners are on Google Cloud, 372 | you can leverage the Application Default Credentials of the instance. This will 373 | authenticate requests as the service account attached to the instance. **This 374 | only works using a custom runner hosted on GCP.** 375 | 376 | ```yaml 377 | jobs: 378 | job_id: 379 | steps: 380 | # ... 381 | 382 | - uses: 'google-github-actions/deploy-cloudrun@v2' 383 | with: 384 | image: 'gcr.io/cloudrun/hello' 385 | service: 'hello-cloud-run' 386 | ``` 387 | 388 | The action will automatically detect and use the Application Default 389 | Credentials. 390 | 391 | ## Example Workflows 392 | 393 | * [Deploy from source](https://github.com/google-github-actions/example-workflows/blob/main/workflows/deploy-cloudrun/cloudrun-source.yml) 394 | 395 | * [Build and deploy a container](https://github.com/google-github-actions/example-workflows/blob/main/workflows/deploy-cloudrun/cloudrun-docker.yml) 396 | 397 | 398 | [cloud-run]: https://cloud.google.com/run 399 | [sa]: https://cloud.google.com/iam/docs/creating-managing-service-accounts 400 | [wif]: https://cloud.google.com/iam/docs/workload-identity-federation 401 | [create-key]: https://cloud.google.com/iam/docs/creating-managing-service-account-keys 402 | [gh-runners]: https://help.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners 403 | [gh-secret]: https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets 404 | [setup-gcloud]: ./setup-gcloud 405 | [artifact-api]: https://console.cloud.google.com/flows/enableapi?apiid=artifactregistry.googleapis.com 406 | [repo]: https://cloud.google.com/artifact-registry/docs/manage-repos 407 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: 'Deploy to Cloud Run' 16 | author: 'Google LLC' 17 | description: |- 18 | Use this action to deploy a container or source code to Google Cloud Run. 19 | 20 | inputs: 21 | service: 22 | description: |- 23 | ID of the service or fully-qualified identifier of the service. This is 24 | required unless providing `metadata` or `job`. 25 | required: false 26 | 27 | job: 28 | description: |- 29 | ID of the job or fully-qualified identifier of the job. This is required 30 | unless providing `metadata` or `service`. 31 | required: false 32 | 33 | metadata: 34 | description: |- 35 | YAML service description for the Cloud Run service. This is required 36 | unless providing `service` or `job`. 37 | required: false 38 | 39 | image: 40 | description: |- 41 | (Required, unless providing `metadata` or `source`) Fully-qualified name 42 | of the container image to deploy. For example: 43 | 44 | gcr.io/cloudrun/hello:latest 45 | 46 | or 47 | 48 | us-docker.pkg.dev/my-project/my-container/image:1.2.3 49 | required: false 50 | 51 | source: 52 | description: |- 53 | (Required, unless providing `metadata`, `image`, or `job`) Path to source 54 | to deploy. If specified, this will deploy the Cloud Run service from the 55 | code specified at the given source directory. 56 | 57 | Learn more about the required permissions in [Deploying from source 58 | code](https://cloud.google.com/run/docs/deploying-source-code). 59 | required: false 60 | 61 | suffix: 62 | description: |- 63 | String suffix to append to the revision name. Revision names always start 64 | with the service name automatically. For example, specifying `v1` for a 65 | service named `helloworld`, would lead to a revision named 66 | `helloworld-v1`. This option only applies to services. 67 | required: false 68 | 69 | env_vars: 70 | description: |- 71 | List of environment variables that should be set in the environment. 72 | These are comma-separated or newline-separated `KEY=VALUE`. Keys or values 73 | that contain separators must be escaped with a backslash (e.g. `\,` or 74 | `\\n`) unless quoted. Any leading or trailing whitespace is trimmed unless 75 | values are quoted. 76 | 77 | env_vars: |- 78 | FRUIT=apple 79 | SENTENCE=" this will retain leading and trailing spaces " 80 | 81 | This value will only be set if the input is a non-empty value. If a 82 | non-empty value is given, the field values will be overwritten (not 83 | merged). To remove all values, set the value to the literal string `{}`. 84 | 85 | If both `env_vars` and `env_vars_file` are specified, the keys in 86 | `env_vars` will take precedence over the keys in `env_vars_file`. 87 | required: false 88 | 89 | env_vars_file: 90 | description: |- 91 | Path to a file on disk, relative to the workspace, that defines 92 | environment variables. The file can be newline-separated KEY=VALUE pairs, 93 | JSON, or YAML format. If both `env_vars` and `env_vars_file` are 94 | specified, the keys in env_vars will take precedence over the keys in 95 | env_vars_file. 96 | 97 | NAME=person 98 | EMAILS=foo@bar.com\,zip@zap.com 99 | 100 | When specified as KEY=VALUE pairs, the same escaping rules apply as 101 | described in `env_vars`. You do not have to escape YAML or JSON. 102 | 103 | If both `env_vars` and `env_vars_file` are specified, the keys in 104 | `env_vars` will take precedence over the keys in `env_vars_file`. 105 | 106 | **⚠️ DEPRECATION NOTICE:** This input is deprecated and will be removed in 107 | the next major version release. 108 | required: false 109 | 110 | env_vars_update_strategy: 111 | description: |- 112 | Controls how the environment variables are set on the Cloud Run service. 113 | If set to "merge", then the environment variables are _merged_ with any 114 | upstream values. If set to "overwrite", then all environment variables on 115 | the Cloud Run service will be replaced with exactly the values given by 116 | the GitHub Action (making it authoritative). 117 | default: 'merge' 118 | required: true 119 | 120 | secrets: 121 | description: |- 122 | List of KEY=VALUE pairs to use as secrets. These are comma-separated or 123 | newline-separated `KEY=VALUE`. Keys or values that contain separators must 124 | be escaped with a backslash (e.g. `\,` or `\\n`) unless quoted. Any 125 | leading or trailing whitespace is trimmed unless values are quoted. 126 | 127 | These can either be injected as environment variables or mounted as 128 | volumes. Keys starting with a forward slash '/' are mount paths. All other 129 | keys correspond to environment variables: 130 | 131 | with: 132 | secrets: |- 133 | # As an environment variable: 134 | KEY1=secret-key-1:latest 135 | 136 | # As a volume mount: 137 | /secrets/api/key=secret-key-2:latest 138 | 139 | This value will only be set if the input is a non-empty value. If a 140 | non-empty value is given, the field values will be overwritten (not 141 | merged). To remove all values, set the value to the literal string `{}`. 142 | required: false 143 | 144 | secrets_update_strategy: 145 | description: |- 146 | Controls how the secrets are set on the Cloud Run service. If set to 147 | `merge`, then the secrets are merged with any upstream values. If set to 148 | `overwrite`, then all secrets on the Cloud Run service will be replaced 149 | with exactly the values given by the GitHub Action (making it 150 | authoritative). 151 | default: 'merge' 152 | required: true 153 | 154 | labels: 155 | description: |- 156 | List of labels that should be set on the function. These are 157 | comma-separated or newline-separated `KEY=VALUE`. Keys or values that 158 | contain separators must be escaped with a backslash (e.g. `\,` or `\\n`) 159 | unless quoted. Any leading or trailing whitespace is trimmed unless values 160 | are quoted. 161 | 162 | labels: |- 163 | labela=my-label 164 | labelb=my-other-label 165 | 166 | This value will only be set if the input is a non-empty value. If a 167 | non-empty value is given, the field values will be overwritten (not 168 | merged). To remove all values, set the value to the literal string `{}`. 169 | 170 | Google Cloud restricts the allowed values and length for labels. Please 171 | see the Google Cloud documentation for labels for more information. 172 | required: false 173 | 174 | skip_default_labels: 175 | description: |- 176 | Skip applying the special annotation labels that indicate the deployment 177 | came from GitHub Actions. The GitHub Action will automatically apply the 178 | following labels which Cloud Run uses to enhance the user experience: 179 | 180 | managed-by: github-actions 181 | commit-sha: 182 | 183 | Setting this to `true` will skip adding these special labels. 184 | required: false 185 | default: 'false' 186 | 187 | tag: 188 | description: |- 189 | Traffic tag to assign to the newly-created revision. This option only 190 | applies to services. 191 | required: false 192 | 193 | timeout: 194 | description: |- 195 | Maximum request execution time, specified as a duration like "10m5s" for 196 | ten minutes and 5 seconds. 197 | required: false 198 | 199 | flags: 200 | description: |- 201 | Space separate list of additional Cloud Run flags to pass to the deploy 202 | command. This can be used to apply advanced features that are not exposed 203 | via this GitHub Action. For Cloud Run services, this command will be 204 | `gcloud run deploy`. For Cloud Run jobs, this command will be `gcloud jobs 205 | deploy`. 206 | 207 | with: 208 | flags: '--add-cloudsql-instances=...' 209 | 210 | Flags that include other flags must quote the _entire_ outer flag value. For 211 | example, to pass `--args=-X=123`: 212 | 213 | with: 214 | flags: '--add-cloudsql-instances=... "--args=-X=123"' 215 | 216 | See the [complete list of 217 | flags](https://cloud.google.com/sdk/gcloud/reference/run/deploy#FLAGS) for 218 | more information. 219 | 220 | Please note, this GitHub Action does not parse or validate the flags. You 221 | are responsible for making sure the flags are available on the gcloud 222 | version and subcommand. 223 | required: false 224 | 225 | no_traffic: 226 | description: |- 227 | If true, the newly deployed revision will not receive traffic. This option 228 | only applies to services. 229 | default: 'false' 230 | required: false 231 | 232 | revision_traffic: 233 | description: |- 234 | Comma-separated list of revision traffic assignments. 235 | 236 | with: 237 | revision_traffic: 'my-revision=10' # percentage 238 | 239 | To update traffic to the latest revision, use the special tag "LATEST": 240 | 241 | with: 242 | revision_traffic: 'LATEST=100' 243 | 244 | This is mutually-exclusive with `tag_traffic`. This option only applies 245 | to services. 246 | required: false 247 | 248 | tag_traffic: 249 | description: |- 250 | Comma-separated list of tag traffic assignments. 251 | 252 | with: 253 | tag_traffic: 'my-tag=10' # percentage 254 | 255 | This is mutually-exclusive with `revision_traffic`. This option only 256 | applies to services. 257 | required: false 258 | 259 | update_traffic_flags: 260 | description: |- 261 | Space separate list of additional Cloud Run flags to pass to the `gcloud 262 | run services update-traffic` command. This can be used to apply advanced 263 | features that are not exposed via this GitHub Action. This flag only 264 | applies when `revision_traffic` or `tag_traffic` is set. 265 | 266 | with: 267 | traffic_flags: '--set-tags=...' 268 | 269 | Flags that include other flags must quote the _entire_ outer flag value. For 270 | example, to pass `--args=-X=123`: 271 | 272 | with: 273 | flags: '--set-tags=... "--args=-X=123"' 274 | 275 | See the [complete list of 276 | flags](https://cloud.google.com/sdk/gcloud/reference/run/services/update#FLAGS) 277 | for more information. 278 | 279 | Please note, this GitHub Action does not parse or validate the flags. You 280 | are responsible for making sure the flags are available on the gcloud 281 | version and subcommand. 282 | required: false 283 | 284 | project_id: 285 | description: |- 286 | ID of the Google Cloud project in which to deploy the service. 287 | required: false 288 | 289 | region: 290 | description: |- 291 | Region in which the Cloud Run services are deployed. 292 | default: 'us-central1' 293 | required: false 294 | 295 | gcloud_version: 296 | description: |- 297 | Version of the Cloud SDK to install. If unspecified or set to "latest", 298 | the latest available gcloud SDK version for the target platform will be 299 | installed. Example: "290.0.1". 300 | required: false 301 | 302 | gcloud_component: 303 | description: |- 304 | Version of the Cloud SDK components to install and use. 305 | required: false 306 | 307 | outputs: 308 | url: 309 | description: |- 310 | The URL of the Cloud Run service. 311 | 312 | branding: 313 | icon: 'chevrons-right' 314 | color: 'blue' 315 | 316 | runs: 317 | using: 'node20' 318 | main: 'dist/main/index.js' 319 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import ts from 'typescript-eslint'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | 5 | import prettierRecommended from 'eslint-plugin-prettier/recommended'; 6 | 7 | export default ts.config( 8 | js.configs.recommended, 9 | ts.configs.eslintRecommended, 10 | { 11 | files: ['**/*.ts', '**/*.tsx'], 12 | languageOptions: { 13 | parser: tsParser, 14 | }, 15 | }, 16 | { ignores: ['dist/', '**/*.js'] }, 17 | { 18 | rules: { 19 | 'no-unused-vars': 'off', 20 | }, 21 | }, 22 | prettierRecommended, 23 | ); 24 | -------------------------------------------------------------------------------- /example-app/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | README.md 3 | node_modules 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /example-app/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google, LLC. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Use the official lightweight Node.js 20 image. 16 | # https://hub.docker.com/_/node 17 | FROM node:20-slim 18 | 19 | # Create and change to the app directory. 20 | WORKDIR /usr/src/app 21 | 22 | # Copy application dependency manifests to the container image. 23 | # A wildcard is used to ensure both package.json AND package-lock.json are copied. 24 | # Copying this separately prevents re-running npm install on every code change. 25 | COPY package*.json ./ 26 | 27 | # Install production dependencies. 28 | RUN npm ci 29 | 30 | # Copy local code to the container image. 31 | COPY . ./ 32 | 33 | # Run the web service on container startup. 34 | CMD [ "npm", "start" ] 35 | -------------------------------------------------------------------------------- /example-app/index.js: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Google, LLC. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | const package = require('./package.json') 16 | const express = require('express'); 17 | const app = express(); 18 | 19 | app.get('/', (req, res) => { 20 | console.log(`${package.name} received a request.`); 21 | res.send(`Congratulations, you successfully deployed a container image to Cloud Run!`); 22 | }); 23 | 24 | const port = process.env.PORT || 8080; 25 | app.listen(port, () => { 26 | console.log(`${package.name} listening on port: ${port}`); 27 | }); 28 | -------------------------------------------------------------------------------- /example-app/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "helloworld", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "accepts": { 8 | "version": "1.3.8", 9 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", 10 | "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", 11 | "requires": { 12 | "mime-types": "~2.1.34", 13 | "negotiator": "0.6.3" 14 | } 15 | }, 16 | "array-flatten": { 17 | "version": "1.1.1", 18 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 19 | "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" 20 | }, 21 | "body-parser": { 22 | "version": "1.20.3", 23 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", 24 | "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", 25 | "requires": { 26 | "bytes": "3.1.2", 27 | "content-type": "~1.0.5", 28 | "debug": "2.6.9", 29 | "depd": "2.0.0", 30 | "destroy": "1.2.0", 31 | "http-errors": "2.0.0", 32 | "iconv-lite": "0.4.24", 33 | "on-finished": "2.4.1", 34 | "qs": "6.13.0", 35 | "raw-body": "2.5.2", 36 | "type-is": "~1.6.18", 37 | "unpipe": "1.0.0" 38 | } 39 | }, 40 | "bytes": { 41 | "version": "3.1.2", 42 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", 43 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" 44 | }, 45 | "call-bind": { 46 | "version": "1.0.8", 47 | "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", 48 | "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", 49 | "requires": { 50 | "call-bind-apply-helpers": "^1.0.0", 51 | "es-define-property": "^1.0.0", 52 | "get-intrinsic": "^1.2.4", 53 | "set-function-length": "^1.2.2" 54 | } 55 | }, 56 | "call-bind-apply-helpers": { 57 | "version": "1.0.1", 58 | "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", 59 | "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", 60 | "requires": { 61 | "es-errors": "^1.3.0", 62 | "function-bind": "^1.1.2" 63 | } 64 | }, 65 | "content-disposition": { 66 | "version": "0.5.4", 67 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", 68 | "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 69 | "requires": { 70 | "safe-buffer": "5.2.1" 71 | } 72 | }, 73 | "content-type": { 74 | "version": "1.0.5", 75 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", 76 | "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" 77 | }, 78 | "cookie": { 79 | "version": "0.7.1", 80 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", 81 | "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==" 82 | }, 83 | "cookie-signature": { 84 | "version": "1.0.6", 85 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 86 | "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" 87 | }, 88 | "debug": { 89 | "version": "2.6.9", 90 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 91 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 92 | "requires": { 93 | "ms": "2.0.0" 94 | } 95 | }, 96 | "define-data-property": { 97 | "version": "1.1.4", 98 | "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", 99 | "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", 100 | "requires": { 101 | "es-define-property": "^1.0.0", 102 | "es-errors": "^1.3.0", 103 | "gopd": "^1.0.1" 104 | } 105 | }, 106 | "depd": { 107 | "version": "2.0.0", 108 | "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", 109 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" 110 | }, 111 | "destroy": { 112 | "version": "1.2.0", 113 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", 114 | "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" 115 | }, 116 | "dunder-proto": { 117 | "version": "1.0.0", 118 | "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.0.tgz", 119 | "integrity": "sha512-9+Sj30DIu+4KvHqMfLUGLFYL2PkURSYMVXJyXe92nFRvlYq5hBjLEhblKB+vkd/WVlUYMWigiY07T91Fkk0+4A==", 120 | "requires": { 121 | "call-bind-apply-helpers": "^1.0.0", 122 | "es-errors": "^1.3.0", 123 | "gopd": "^1.2.0" 124 | } 125 | }, 126 | "ee-first": { 127 | "version": "1.1.1", 128 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 129 | "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" 130 | }, 131 | "encodeurl": { 132 | "version": "2.0.0", 133 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", 134 | "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" 135 | }, 136 | "es-define-property": { 137 | "version": "1.0.1", 138 | "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", 139 | "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" 140 | }, 141 | "es-errors": { 142 | "version": "1.3.0", 143 | "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 144 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" 145 | }, 146 | "escape-html": { 147 | "version": "1.0.3", 148 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 149 | "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" 150 | }, 151 | "etag": { 152 | "version": "1.8.1", 153 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 154 | "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" 155 | }, 156 | "express": { 157 | "version": "4.21.2", 158 | "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", 159 | "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", 160 | "requires": { 161 | "accepts": "~1.3.8", 162 | "array-flatten": "1.1.1", 163 | "body-parser": "1.20.3", 164 | "content-disposition": "0.5.4", 165 | "content-type": "~1.0.4", 166 | "cookie": "0.7.1", 167 | "cookie-signature": "1.0.6", 168 | "debug": "2.6.9", 169 | "depd": "2.0.0", 170 | "encodeurl": "~2.0.0", 171 | "escape-html": "~1.0.3", 172 | "etag": "~1.8.1", 173 | "finalhandler": "1.3.1", 174 | "fresh": "0.5.2", 175 | "http-errors": "2.0.0", 176 | "merge-descriptors": "1.0.3", 177 | "methods": "~1.1.2", 178 | "on-finished": "2.4.1", 179 | "parseurl": "~1.3.3", 180 | "path-to-regexp": "0.1.12", 181 | "proxy-addr": "~2.0.7", 182 | "qs": "6.13.0", 183 | "range-parser": "~1.2.1", 184 | "safe-buffer": "5.2.1", 185 | "send": "0.19.0", 186 | "serve-static": "1.16.2", 187 | "setprototypeof": "1.2.0", 188 | "statuses": "2.0.1", 189 | "type-is": "~1.6.18", 190 | "utils-merge": "1.0.1", 191 | "vary": "~1.1.2" 192 | } 193 | }, 194 | "finalhandler": { 195 | "version": "1.3.1", 196 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", 197 | "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", 198 | "requires": { 199 | "debug": "2.6.9", 200 | "encodeurl": "~2.0.0", 201 | "escape-html": "~1.0.3", 202 | "on-finished": "2.4.1", 203 | "parseurl": "~1.3.3", 204 | "statuses": "2.0.1", 205 | "unpipe": "~1.0.0" 206 | } 207 | }, 208 | "forwarded": { 209 | "version": "0.2.0", 210 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", 211 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" 212 | }, 213 | "fresh": { 214 | "version": "0.5.2", 215 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 216 | "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" 217 | }, 218 | "function-bind": { 219 | "version": "1.1.2", 220 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 221 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" 222 | }, 223 | "get-intrinsic": { 224 | "version": "1.2.5", 225 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.5.tgz", 226 | "integrity": "sha512-Y4+pKa7XeRUPWFNvOOYHkRYrfzW07oraURSvjDmRVOJ748OrVmeXtpE4+GCEHncjCjkTxPNRt8kEbxDhsn6VTg==", 227 | "requires": { 228 | "call-bind-apply-helpers": "^1.0.0", 229 | "dunder-proto": "^1.0.0", 230 | "es-define-property": "^1.0.1", 231 | "es-errors": "^1.3.0", 232 | "function-bind": "^1.1.2", 233 | "gopd": "^1.2.0", 234 | "has-symbols": "^1.1.0", 235 | "hasown": "^2.0.2" 236 | } 237 | }, 238 | "gopd": { 239 | "version": "1.2.0", 240 | "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", 241 | "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" 242 | }, 243 | "has-property-descriptors": { 244 | "version": "1.0.2", 245 | "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", 246 | "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", 247 | "requires": { 248 | "es-define-property": "^1.0.0" 249 | } 250 | }, 251 | "has-symbols": { 252 | "version": "1.1.0", 253 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", 254 | "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" 255 | }, 256 | "hasown": { 257 | "version": "2.0.2", 258 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 259 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 260 | "requires": { 261 | "function-bind": "^1.1.2" 262 | } 263 | }, 264 | "http-errors": { 265 | "version": "2.0.0", 266 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", 267 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 268 | "requires": { 269 | "depd": "2.0.0", 270 | "inherits": "2.0.4", 271 | "setprototypeof": "1.2.0", 272 | "statuses": "2.0.1", 273 | "toidentifier": "1.0.1" 274 | } 275 | }, 276 | "iconv-lite": { 277 | "version": "0.4.24", 278 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 279 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 280 | "requires": { 281 | "safer-buffer": ">= 2.1.2 < 3" 282 | } 283 | }, 284 | "inherits": { 285 | "version": "2.0.4", 286 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 287 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 288 | }, 289 | "ipaddr.js": { 290 | "version": "1.9.1", 291 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", 292 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" 293 | }, 294 | "media-typer": { 295 | "version": "0.3.0", 296 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 297 | "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" 298 | }, 299 | "merge-descriptors": { 300 | "version": "1.0.3", 301 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", 302 | "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" 303 | }, 304 | "methods": { 305 | "version": "1.1.2", 306 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 307 | "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" 308 | }, 309 | "mime": { 310 | "version": "1.6.0", 311 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 312 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" 313 | }, 314 | "mime-db": { 315 | "version": "1.52.0", 316 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 317 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" 318 | }, 319 | "mime-types": { 320 | "version": "2.1.35", 321 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 322 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 323 | "requires": { 324 | "mime-db": "1.52.0" 325 | } 326 | }, 327 | "ms": { 328 | "version": "2.0.0", 329 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 330 | "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" 331 | }, 332 | "negotiator": { 333 | "version": "0.6.3", 334 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", 335 | "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" 336 | }, 337 | "object-inspect": { 338 | "version": "1.13.3", 339 | "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", 340 | "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==" 341 | }, 342 | "on-finished": { 343 | "version": "2.4.1", 344 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", 345 | "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", 346 | "requires": { 347 | "ee-first": "1.1.1" 348 | } 349 | }, 350 | "parseurl": { 351 | "version": "1.3.3", 352 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", 353 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 354 | }, 355 | "path-to-regexp": { 356 | "version": "0.1.12", 357 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", 358 | "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" 359 | }, 360 | "proxy-addr": { 361 | "version": "2.0.7", 362 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", 363 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 364 | "requires": { 365 | "forwarded": "0.2.0", 366 | "ipaddr.js": "1.9.1" 367 | } 368 | }, 369 | "qs": { 370 | "version": "6.13.0", 371 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", 372 | "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", 373 | "requires": { 374 | "side-channel": "^1.0.6" 375 | } 376 | }, 377 | "range-parser": { 378 | "version": "1.2.1", 379 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", 380 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" 381 | }, 382 | "raw-body": { 383 | "version": "2.5.2", 384 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", 385 | "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", 386 | "requires": { 387 | "bytes": "3.1.2", 388 | "http-errors": "2.0.0", 389 | "iconv-lite": "0.4.24", 390 | "unpipe": "1.0.0" 391 | } 392 | }, 393 | "safe-buffer": { 394 | "version": "5.2.1", 395 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 396 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" 397 | }, 398 | "safer-buffer": { 399 | "version": "2.1.2", 400 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 401 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 402 | }, 403 | "send": { 404 | "version": "0.19.0", 405 | "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", 406 | "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", 407 | "requires": { 408 | "debug": "2.6.9", 409 | "depd": "2.0.0", 410 | "destroy": "1.2.0", 411 | "encodeurl": "~1.0.2", 412 | "escape-html": "~1.0.3", 413 | "etag": "~1.8.1", 414 | "fresh": "0.5.2", 415 | "http-errors": "2.0.0", 416 | "mime": "1.6.0", 417 | "ms": "2.1.3", 418 | "on-finished": "2.4.1", 419 | "range-parser": "~1.2.1", 420 | "statuses": "2.0.1" 421 | }, 422 | "dependencies": { 423 | "encodeurl": { 424 | "version": "1.0.2", 425 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 426 | "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" 427 | }, 428 | "ms": { 429 | "version": "2.1.3", 430 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 431 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 432 | } 433 | } 434 | }, 435 | "serve-static": { 436 | "version": "1.16.2", 437 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", 438 | "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", 439 | "requires": { 440 | "encodeurl": "~2.0.0", 441 | "escape-html": "~1.0.3", 442 | "parseurl": "~1.3.3", 443 | "send": "0.19.0" 444 | } 445 | }, 446 | "set-function-length": { 447 | "version": "1.2.2", 448 | "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", 449 | "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", 450 | "requires": { 451 | "define-data-property": "^1.1.4", 452 | "es-errors": "^1.3.0", 453 | "function-bind": "^1.1.2", 454 | "get-intrinsic": "^1.2.4", 455 | "gopd": "^1.0.1", 456 | "has-property-descriptors": "^1.0.2" 457 | } 458 | }, 459 | "setprototypeof": { 460 | "version": "1.2.0", 461 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", 462 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" 463 | }, 464 | "side-channel": { 465 | "version": "1.0.6", 466 | "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", 467 | "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", 468 | "requires": { 469 | "call-bind": "^1.0.7", 470 | "es-errors": "^1.3.0", 471 | "get-intrinsic": "^1.2.4", 472 | "object-inspect": "^1.13.1" 473 | } 474 | }, 475 | "statuses": { 476 | "version": "2.0.1", 477 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", 478 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" 479 | }, 480 | "toidentifier": { 481 | "version": "1.0.1", 482 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", 483 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" 484 | }, 485 | "type-is": { 486 | "version": "1.6.18", 487 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", 488 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 489 | "requires": { 490 | "media-typer": "0.3.0", 491 | "mime-types": "~2.1.24" 492 | } 493 | }, 494 | "unpipe": { 495 | "version": "1.0.0", 496 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 497 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" 498 | }, 499 | "utils-merge": { 500 | "version": "1.0.1", 501 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 502 | "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" 503 | }, 504 | "vary": { 505 | "version": "1.1.2", 506 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 507 | "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" 508 | } 509 | } 510 | } 511 | -------------------------------------------------------------------------------- /example-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "helloworld", 3 | "version": "1.0.0", 4 | "description": "Simple hello world sample in Node", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "author": "Google LLC", 10 | "license": "Apache-2.0", 11 | "dependencies": { 12 | "express": "^4.21.2" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deploy-cloudrun", 3 | "version": "2.7.3", 4 | "description": "Github Action: Deploy to Google Cloud Run", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "rm -rf dist/ && ncc build -m src/main.ts -o dist/main", 8 | "docs": "./node_modules/.bin/actions-gen-readme", 9 | "lint": "eslint .", 10 | "format": "eslint . --fix", 11 | "test": "node --require ts-node/register --test-reporter spec --test tests/unit/main.test.ts tests/unit/output-parser.test.ts", 12 | "e2e-tests": "node --require ts-node/register --test-reporter spec --test tests/e2e.test.ts" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/google-github-actions/deploy-cloudrun.git" 17 | }, 18 | "keywords": [ 19 | "actions", 20 | "google", 21 | "cloud run", 22 | "cloud", 23 | "run", 24 | "containers" 25 | ], 26 | "author": "Google LLC", 27 | "license": "Apache-2.0", 28 | "bugs": { 29 | "url": "https://github.com/google-github-actions/deploy-cloudrun/issues" 30 | }, 31 | "homepage": "https://github.com/google-github-actions/deploy-cloudrun#readme", 32 | "dependencies": { 33 | "@actions/core": "^1.11.1", 34 | "@actions/exec": "^1.1.1", 35 | "@actions/tool-cache": "^2.0.2", 36 | "@google-github-actions/actions-utils": "^0.8.6", 37 | "@google-github-actions/setup-cloud-sdk": "^1.1.9", 38 | "yaml": "^2.7.0" 39 | }, 40 | "devDependencies": { 41 | "@eslint/eslintrc": "^3.2.0", 42 | "@eslint/js": "^9.19.0", 43 | "@types/node": "^22.13.0", 44 | "@typescript-eslint/eslint-plugin": "^8.22.0", 45 | "@typescript-eslint/parser": "^8.22.0", 46 | "@vercel/ncc": "^0.38.3", 47 | "eslint-config-prettier": "^10.0.1", 48 | "eslint-plugin-prettier": "^5.2.3", 49 | "eslint": "^9.19.0", 50 | "googleapis": "^144.0.0", 51 | "prettier": "^3.4.2", 52 | "ts-node": "^10.9.2", 53 | "typescript-eslint": "^8.22.0", 54 | "typescript": "^5.7.3" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import path from 'path'; 18 | 19 | import { 20 | addPath, 21 | debug as logDebug, 22 | getInput, 23 | info as logInfo, 24 | setFailed, 25 | setOutput, 26 | warning as logWarning, 27 | } from '@actions/core'; 28 | import { getExecOutput } from '@actions/exec'; 29 | import * as toolCache from '@actions/tool-cache'; 30 | import { readFile } from 'fs/promises'; 31 | import { parse as parseYAML } from 'yaml'; 32 | 33 | import { 34 | errorMessage, 35 | isPinnedToHead, 36 | joinKVStringForGCloud, 37 | KVPair, 38 | parseBoolean, 39 | parseCSV, 40 | parseFlags, 41 | parseKVString, 42 | parseKVStringAndFile, 43 | pinnedToHeadWarning, 44 | presence, 45 | } from '@google-github-actions/actions-utils'; 46 | import { 47 | authenticateGcloudSDK, 48 | getLatestGcloudSDKVersion, 49 | getToolCommand, 50 | installComponent as installGcloudComponent, 51 | installGcloudSDK, 52 | isInstalled as isGcloudInstalled, 53 | } from '@google-github-actions/setup-cloud-sdk'; 54 | 55 | import { parseDeployResponse, parseUpdateTrafficResponse } from './output-parser'; 56 | 57 | // Do not listen to the linter - this can NOT be rewritten as an ES6 import 58 | // statement. 59 | const { version: appVersion } = require('../package.json'); 60 | 61 | // isDebug returns true if runner debugging or step debugging is enabled. 62 | const isDebug = 63 | parseBoolean(process.env.ACTIONS_RUNNER_DEBUG) || parseBoolean(process.env.ACTIONS_STEP_DEBUG); 64 | 65 | /** 66 | * DeployCloudRunOutputs are the common GitHub action outputs created by this action 67 | */ 68 | export interface DeployCloudRunOutputs { 69 | url?: string | null | undefined; // Type required to match run_v1.Schema$Service.status.url 70 | } 71 | 72 | /** 73 | * ResponseTypes are the gcloud command response formats 74 | */ 75 | enum ResponseTypes { 76 | DEPLOY, 77 | UPDATE_TRAFFIC, 78 | } 79 | 80 | /** 81 | * Executes the main action. It includes the main business logic and is the 82 | * primary entry point. It is documented inline. 83 | */ 84 | export async function run(): Promise { 85 | // Register metrics 86 | process.env.CLOUDSDK_CORE_DISABLE_PROMPTS = '1'; 87 | process.env.CLOUDSDK_METRICS_ENVIRONMENT = 'github-actions-deploy-cloudrun'; 88 | process.env.CLOUDSDK_METRICS_ENVIRONMENT_VERSION = appVersion; 89 | process.env.GOOGLE_APIS_USER_AGENT = `google-github-actions:deploy-cloudrun/${appVersion}`; 90 | 91 | // Warn if pinned to HEAD 92 | if (isPinnedToHead()) { 93 | logWarning(pinnedToHeadWarning('v1')); 94 | } 95 | 96 | try { 97 | // Get action inputs 98 | const image = getInput('image'); // Image ie gcr.io/... 99 | let service = getInput('service'); // Service name 100 | const job = getInput('job'); // Job name 101 | const metadata = getInput('metadata'); // YAML file 102 | const projectId = getInput('project_id'); 103 | const gcloudVersion = await computeGcloudVersion(getInput('gcloud_version')); 104 | const gcloudComponent = presence(getInput('gcloud_component')); // Cloud SDK component version 105 | const envVars = getInput('env_vars'); // String of env vars KEY=VALUE,... 106 | const envVarsFile = getInput('env_vars_file'); // File that is a string of env vars KEY=VALUE,... 107 | const envVarsUpdateStrategy = getInput('env_vars_update_strategy') || 'merge'; 108 | const secrets = parseKVString(getInput('secrets')); // String of secrets KEY=VALUE,... 109 | const secretsUpdateStrategy = getInput('secrets_update_strategy') || 'merge'; 110 | const region = parseCSV(getInput('region') || 'us-central1'); 111 | const source = getInput('source'); // Source directory 112 | const suffix = getInput('suffix'); 113 | const tag = getInput('tag'); 114 | const timeout = getInput('timeout'); 115 | const noTraffic = (getInput('no_traffic') || '').toLowerCase() === 'true'; 116 | const revTraffic = getInput('revision_traffic'); 117 | const tagTraffic = getInput('tag_traffic'); 118 | const labels = parseKVString(getInput('labels')); 119 | const skipDefaultLabels = parseBoolean(getInput('skip_default_labels')); 120 | const flags = getInput('flags'); 121 | const updateTrafficFlags = getInput('update_traffic_flags'); 122 | 123 | let deployCmd: string[] = []; 124 | 125 | // Throw errors if inputs aren't valid 126 | if (revTraffic && tagTraffic) { 127 | throw new Error('Only one of `revision_traffic` or `tag_traffic` inputs can be set.'); 128 | } 129 | if ((revTraffic || tagTraffic) && !service) { 130 | throw new Error('No service name set.'); 131 | } 132 | if (source && image) { 133 | throw new Error('Only one of `source` or `image` inputs can be set.'); 134 | } 135 | if (service && job) { 136 | throw new Error('Only one of `service` or `job` inputs can be set.'); 137 | } 138 | 139 | // Deprecation notices 140 | if (envVarsFile) { 141 | logWarning( 142 | `The "env_vars_file" input is deprecated and will be removed in a ` + 143 | `future major release. To source values from a file, read the file ` + 144 | `in a separate GitHub Actions step and set the contents as an output. ` + 145 | `Alternatively, there are many community actions that automate ` + 146 | `reading files.`, 147 | ); 148 | } 149 | 150 | // Validate gcloud component input 151 | if (gcloudComponent && gcloudComponent !== 'alpha' && gcloudComponent !== 'beta') { 152 | throw new Error(`invalid input received for gcloud_component: ${gcloudComponent}`); 153 | } 154 | 155 | // Find base command 156 | if (metadata) { 157 | const contents = await readFile(metadata, 'utf8'); 158 | const parsed = parseYAML(contents); 159 | 160 | // Extract service name from metadata template 161 | const name = parsed?.metadata?.name; 162 | if (!name) { 163 | throw new Error(`${metadata} is missing 'metadata.name'`); 164 | } 165 | if (service && service != name) { 166 | throw new Error( 167 | `service name in ${metadata} ("${name}") does not match GitHub ` + 168 | `Actions service input ("${service}")`, 169 | ); 170 | } 171 | service = name; 172 | 173 | const kind = parsed?.kind; 174 | if (kind === 'Service') { 175 | deployCmd = ['run', 'services', 'replace', metadata]; 176 | } else if (kind === 'Job') { 177 | deployCmd = ['run', 'jobs', 'replace', metadata]; 178 | } else { 179 | throw new Error(`Unkown metadata type "${kind}", expected "Job" or "Service"`); 180 | } 181 | } else if (job) { 182 | logWarning( 183 | `Support for Cloud Run jobs in this GitHub Action is in beta and is ` + 184 | `not covered by the semver backwards compatibility guarantee.`, 185 | ); 186 | 187 | deployCmd = ['run', 'jobs', 'deploy', job]; 188 | 189 | if (image) { 190 | deployCmd.push('--image', image); 191 | } else if (source) { 192 | deployCmd.push('--source', source); 193 | } 194 | 195 | // Set optional flags from inputs 196 | setEnvVarsFlags(deployCmd, envVars, envVarsFile, envVarsUpdateStrategy); 197 | setSecretsFlags(deployCmd, secrets, secretsUpdateStrategy); 198 | 199 | // There is no --update-secrets flag on jobs, but there will be in the 200 | // future. At that point, we can remove this. 201 | const idx = deployCmd.indexOf('--update-secrets'); 202 | if (idx >= 0) { 203 | logWarning( 204 | `Cloud Run does not allow updating secrets on jobs, ignoring ` + 205 | `"secrets_update_strategy" value of "merge"`, 206 | ); 207 | deployCmd[idx] = '--set-secrets'; 208 | } 209 | 210 | // Compile the labels 211 | const defLabels = skipDefaultLabels ? {} : defaultLabels(); 212 | const compiledLabels = Object.assign({}, defLabels, labels); 213 | if (compiledLabels && Object.keys(compiledLabels).length > 0) { 214 | deployCmd.push('--labels', joinKVStringForGCloud(compiledLabels)); 215 | } 216 | } else { 217 | deployCmd = ['run', 'deploy', service]; 218 | 219 | if (image) { 220 | deployCmd.push('--image', image); 221 | } else if (source) { 222 | deployCmd.push('--source', source); 223 | } 224 | 225 | // Set optional flags from inputs 226 | setEnvVarsFlags(deployCmd, envVars, envVarsFile, envVarsUpdateStrategy); 227 | setSecretsFlags(deployCmd, secrets, secretsUpdateStrategy); 228 | 229 | if (tag) { 230 | deployCmd.push('--tag', tag); 231 | } 232 | if (suffix) deployCmd.push('--revision-suffix', suffix); 233 | if (noTraffic) deployCmd.push('--no-traffic'); 234 | if (timeout) deployCmd.push('--timeout', timeout); 235 | 236 | // Compile the labels 237 | const defLabels = skipDefaultLabels ? {} : defaultLabels(); 238 | const compiledLabels = Object.assign({}, defLabels, labels); 239 | if (compiledLabels && Object.keys(compiledLabels).length > 0) { 240 | deployCmd.push('--update-labels', joinKVStringForGCloud(compiledLabels)); 241 | } 242 | } 243 | 244 | // Traffic flags 245 | let updateTrafficCmd = ['run', 'services', 'update-traffic', service]; 246 | if (revTraffic) updateTrafficCmd.push('--to-revisions', revTraffic); 247 | if (tagTraffic) updateTrafficCmd.push('--to-tags', tagTraffic); 248 | 249 | // Push common flags 250 | deployCmd.push('--format', 'json'); 251 | updateTrafficCmd.push('--format', 'json'); 252 | 253 | if (region?.length > 0) { 254 | const regions = region 255 | .flat() 256 | .filter((e) => e !== undefined && e !== null && e !== '') 257 | .join(','); 258 | deployCmd.push('--region', regions); 259 | updateTrafficCmd.push('--region', regions); 260 | } 261 | if (projectId) { 262 | deployCmd.push('--project', projectId); 263 | updateTrafficCmd.push('--project', projectId); 264 | } 265 | 266 | // Add optional deploy flags 267 | if (flags) { 268 | const flagList = parseFlags(flags); 269 | if (flagList) { 270 | deployCmd = deployCmd.concat(flagList); 271 | } 272 | } 273 | 274 | // Add optional update-traffic flags 275 | if (updateTrafficFlags) { 276 | const flagList = parseFlags(updateTrafficFlags); 277 | if (flagList) { 278 | updateTrafficCmd = updateTrafficCmd.concat(flagList); 279 | } 280 | } 281 | 282 | // Install gcloud if not already installed. 283 | if (!isGcloudInstalled(gcloudVersion)) { 284 | await installGcloudSDK(gcloudVersion); 285 | } else { 286 | const toolPath = toolCache.find('gcloud', gcloudVersion); 287 | addPath(path.join(toolPath, 'bin')); 288 | } 289 | 290 | // Install gcloud component if needed and prepend the command 291 | if (gcloudComponent) { 292 | await installGcloudComponent(gcloudComponent); 293 | deployCmd.unshift(gcloudComponent); 294 | updateTrafficCmd.unshift(gcloudComponent); 295 | } 296 | 297 | // Authenticate - this comes from google-github-actions/auth. 298 | const credFile = process.env.GOOGLE_GHA_CREDS_PATH; 299 | if (credFile) { 300 | await authenticateGcloudSDK(credFile); 301 | logInfo('Successfully authenticated'); 302 | } else { 303 | logWarning('No authentication found, authenticate with `google-github-actions/auth`.'); 304 | } 305 | 306 | const toolCommand = getToolCommand(); 307 | const options = { silent: !isDebug, ignoreReturnCode: true }; 308 | const commandString = `${toolCommand} ${deployCmd.join(' ')}`; 309 | logInfo(`Running: ${commandString}`); 310 | logDebug( 311 | JSON.stringify({ toolCommand: toolCommand, args: deployCmd, options: options }, null, ' '), 312 | ); 313 | 314 | // Run deploy command 315 | const deployCmdExec = await getExecOutput(toolCommand, deployCmd, options); 316 | if (deployCmdExec.exitCode !== 0) { 317 | const errMsg = 318 | deployCmdExec.stderr || 319 | `command exited ${deployCmdExec.exitCode}, but stderr had no output`; 320 | throw new Error(`failed to execute gcloud command \`${commandString}\`: ${errMsg}`); 321 | } 322 | setActionOutputs(parseDeployResponse(deployCmdExec.stdout, { tag: tag })); 323 | 324 | // Run revision/tag command 325 | if (revTraffic || tagTraffic) { 326 | const updateTrafficExec = await getExecOutput(toolCommand, updateTrafficCmd, options); 327 | if (updateTrafficExec.exitCode !== 0) { 328 | const errMsg = 329 | updateTrafficExec.stderr || 330 | `command exited ${updateTrafficExec.exitCode}, but stderr had no output`; 331 | throw new Error(`failed to execute gcloud command \`${commandString}\`: ${errMsg}`); 332 | } 333 | setActionOutputs(parseUpdateTrafficResponse(updateTrafficExec.stdout)); 334 | } 335 | } catch (err) { 336 | const msg = errorMessage(err); 337 | setFailed(`google-github-actions/deploy-cloudrun failed with: ${msg}`); 338 | } 339 | } 340 | 341 | // Map output response to GitHub Action outputs 342 | export function setActionOutputs(outputs: DeployCloudRunOutputs): void { 343 | Object.keys(outputs).forEach((key: string) => { 344 | setOutput(key, outputs[key as keyof DeployCloudRunOutputs]); 345 | }); 346 | } 347 | 348 | /** 349 | * defaultLabels returns the default labels to apply to the Cloud Run service. 350 | * 351 | * @return KVPair 352 | */ 353 | function defaultLabels(): KVPair { 354 | const rawValues: Record = { 355 | 'managed-by': 'github-actions', 356 | 'commit-sha': process.env.GITHUB_SHA, 357 | }; 358 | 359 | const labels: KVPair = {}; 360 | for (const key in rawValues) { 361 | const value = rawValues[key]; 362 | if (value) { 363 | // Labels can only be lowercase 364 | labels[key] = value.toLowerCase(); 365 | } 366 | } 367 | 368 | return labels; 369 | } 370 | 371 | /** 372 | * computeGcloudVersion computes the appropriate gcloud version for the given 373 | * string. 374 | */ 375 | async function computeGcloudVersion(str: string): Promise { 376 | str = (str || '').trim(); 377 | if (str === '' || str === 'latest') { 378 | return await getLatestGcloudSDKVersion(); 379 | } 380 | return str; 381 | } 382 | 383 | function setEnvVarsFlags(cmd: string[], envVars: string, envVarsFile: string, strategy: string) { 384 | const compiledEnvVars = parseKVStringAndFile(envVars, envVarsFile); 385 | if (compiledEnvVars && Object.keys(compiledEnvVars).length > 0) { 386 | let flag = ''; 387 | if (strategy === 'overwrite') { 388 | flag = '--set-env-vars'; 389 | } else if (strategy === 'merge') { 390 | flag = '--update-env-vars'; 391 | } else { 392 | throw new Error( 393 | `Invalid "env_vars_update_strategy" value "${strategy}", valid values ` + 394 | `are "overwrite" and "merge".`, 395 | ); 396 | } 397 | cmd.push(flag, joinKVStringForGCloud(compiledEnvVars)); 398 | } 399 | } 400 | 401 | function setSecretsFlags(cmd: string[], secrets: KVPair | undefined, strategy: string) { 402 | if (secrets && Object.keys(secrets).length > 0) { 403 | let flag = ''; 404 | if (strategy === 'overwrite') { 405 | flag = '--set-secrets'; 406 | } else if (strategy === 'merge') { 407 | flag = '--update-secrets'; 408 | } else { 409 | throw new Error( 410 | `Invalid "secrets_update_strategy" value "${strategy}", valid values ` + 411 | `are "overwrite" and "merge".`, 412 | ); 413 | } 414 | cmd.push(flag, joinKVStringForGCloud(secrets)); 415 | } 416 | } 417 | 418 | /** 419 | * execute the main function when this module is required directly. 420 | */ 421 | if (require.main === module) { 422 | run(); 423 | } 424 | -------------------------------------------------------------------------------- /src/output-parser.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { DeployCloudRunOutputs } from './main'; 18 | import { run_v1 } from 'googleapis'; 19 | import { errorMessage, presence } from '@google-github-actions/actions-utils'; 20 | 21 | /** 22 | * ParseInputs are the input values from GitHub actions used for parsing logic 23 | */ 24 | export interface ParseInputs { 25 | [key: string]: string | boolean; 26 | } 27 | 28 | /** 29 | * UpdateTrafficItem is the response type for the gcloud run services update-traffic command 30 | */ 31 | interface UpdateTrafficItem { 32 | displayPercent: string; 33 | displayRevisionId: string; 34 | displayTags: string; 35 | key: string; 36 | latestRevision: boolean; 37 | revisionName: string; 38 | serviceUrl: string; 39 | specPercent: string; 40 | specTags: string; 41 | statusPercent: string; 42 | statusTags: string; 43 | tags: string[]; 44 | urls: string[]; 45 | } 46 | 47 | /** 48 | * parseUpdateTrafficResponse parses the gcloud command response for update-traffic 49 | * into a common DeployCloudRunOutputs object 50 | * 51 | * @param stdout 52 | * @returns DeployCloudRunOutputs 53 | */ 54 | export function parseUpdateTrafficResponse(stdout: string | undefined): DeployCloudRunOutputs { 55 | try { 56 | stdout = presence(stdout); 57 | if (!stdout || stdout === '{}' || stdout === '[]') { 58 | return {}; 59 | } 60 | 61 | const outputJSON: UpdateTrafficItem[] = JSON.parse(stdout); 62 | 63 | // Default to service url 64 | const responseItem = outputJSON[0]; 65 | let url = responseItem?.serviceUrl; 66 | 67 | // Maintain current logic to use first tag URL if present 68 | for (const item of outputJSON) { 69 | if (item?.urls?.length) { 70 | url = item.urls[0]; 71 | break; 72 | } 73 | } 74 | 75 | const outputs: DeployCloudRunOutputs = { url: url }; 76 | 77 | return outputs; 78 | } catch (err) { 79 | const msg = errorMessage(err); 80 | throw new Error(`failed to parse update traffic response: ${msg}, stdout: ${stdout}`); 81 | } 82 | } 83 | 84 | /** 85 | * parseDeployResponse parses the gcloud command response for gcloud run deploy/replace 86 | * into a common DeployCloudRunOutputs object 87 | * 88 | * @param stdout Standard output from gcloud command 89 | * @param inputs Action inputs used in parsing logic 90 | * @returns DeployCloudRunOutputs 91 | */ 92 | export function parseDeployResponse( 93 | stdout: string | undefined, 94 | inputs: ParseInputs | undefined, 95 | ): DeployCloudRunOutputs { 96 | try { 97 | stdout = presence(stdout); 98 | if (!stdout || stdout === '{}' || stdout === '[]') { 99 | return {}; 100 | } 101 | 102 | const outputJSON: run_v1.Schema$Service = JSON.parse(stdout); 103 | 104 | // Set outputs 105 | const outputs: DeployCloudRunOutputs = { 106 | url: outputJSON?.status?.url, 107 | }; 108 | 109 | // Maintain current logic to use tag url if provided 110 | if (inputs?.tag) { 111 | const tagInfo = outputJSON?.status?.traffic?.find((t) => t.tag === inputs.tag); 112 | if (tagInfo) { 113 | outputs.url = tagInfo.url; 114 | } 115 | } 116 | 117 | return outputs; 118 | } catch (err) { 119 | const msg = errorMessage(err); 120 | throw new Error( 121 | `failed to parse deploy response: ${msg}, stdout: ${stdout}, inputs: ${JSON.stringify( 122 | inputs, 123 | )}`, 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/e2e.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test } from 'node:test'; 18 | import assert from 'node:assert'; 19 | 20 | import { getExecOutput } from '@actions/exec'; 21 | import { run_v1 } from 'googleapis'; 22 | 23 | import { skipIfMissingEnv } from '@google-github-actions/actions-utils'; 24 | 25 | test( 26 | 'e2e tests', 27 | { 28 | concurrency: true, 29 | skip: skipIfMissingEnv('PROJECT_ID'), 30 | }, 31 | async (suite) => { 32 | let service: run_v1.Schema$Service; 33 | let job: run_v1.Schema$Job; 34 | let metadata: run_v1.Schema$ObjectMeta; 35 | let spec: run_v1.Schema$TaskSpec | run_v1.Schema$RevisionSpec; 36 | 37 | suite.before(async () => { 38 | if (process.env.JOB) { 39 | const output = await getExecOutput('gcloud', [ 40 | 'run', 41 | 'jobs', 42 | 'describe', 43 | process.env.JOB!, 44 | '--project', 45 | process.env.PROJECT_ID!, 46 | '--format', 47 | 'json', 48 | '--region', 49 | 'us-central1', 50 | ]); 51 | job = JSON.parse(output.stdout) as run_v1.Schema$Job; 52 | if (!job) { 53 | throw new Error('failed to find job definition'); 54 | } 55 | metadata = job.spec!.template!.metadata!; 56 | spec = job.spec!.template!.spec!.template!.spec!; 57 | } else if (process.env.SERVICE) { 58 | const output = await getExecOutput('gcloud', [ 59 | 'run', 60 | 'services', 61 | 'describe', 62 | process.env.SERVICE!, 63 | '--project', 64 | process.env.PROJECT_ID!, 65 | '--format', 66 | 'json', 67 | '--region', 68 | 'us-central1', 69 | ]); 70 | service = JSON.parse(output.stdout) as run_v1.Schema$Service; 71 | if (!service) { 72 | throw new Error('failed to find service definition'); 73 | } 74 | metadata = service.spec!.template!.metadata!; 75 | spec = service.spec!.template!.spec!; 76 | } else { 77 | throw new Error(`missing service or job`); 78 | } 79 | }); 80 | 81 | await suite.test('has the correct envvars', { skip: skipIfMissingEnv('ENV') }, async () => { 82 | const expected = parseEnvVars(process.env.ENV!); 83 | const actual = spec.containers?.at(0)?.env?.filter((e) => e?.value); 84 | 85 | const subset = expected.map((e) => { 86 | return actual?.find((a) => a.name == e.name); 87 | }); 88 | 89 | assert.deepStrictEqual(subset, expected); 90 | }); 91 | 92 | await suite.test( 93 | 'has the correct secret vars', 94 | { skip: skipIfMissingEnv('SECRET_ENV') }, 95 | async () => { 96 | const expected = parseEnvVars(process.env.SECRET_ENV!); 97 | const actual = spec.containers 98 | ?.at(0) 99 | ?.env?.filter((entry) => entry && entry.valueFrom) 100 | .map((entry) => { 101 | const ref = entry.valueFrom?.secretKeyRef; 102 | return { name: entry.name, value: `${ref?.name}:${ref?.key}` }; 103 | }); 104 | 105 | const subset = expected.map((e) => { 106 | return actual?.find((a) => a.name == e.name); 107 | }); 108 | 109 | assert.deepStrictEqual(subset, expected); 110 | }, 111 | ); 112 | 113 | await suite.test( 114 | 'has the correct secret volumes', 115 | { skip: skipIfMissingEnv('SECRET_VOLUMES') }, 116 | async () => { 117 | const expected = parseEnvVars(process.env.SECRET_VOLUMES!); 118 | const actual = spec.containers?.at(0)?.volumeMounts?.map((volumeMount) => { 119 | const secretVolume = spec.volumes?.find( 120 | (volume) => volumeMount.name === volume.name, 121 | )?.secret; 122 | const secretName = secretVolume?.secretName; 123 | const secretData = secretVolume?.items?.at(0); 124 | const secretPath = `${volumeMount.mountPath}/${secretData?.path}`; 125 | const secretRef = `${secretName}:${secretData?.key}`; 126 | return { name: secretPath, value: secretRef }; 127 | }); 128 | 129 | const subset = expected.map((e) => { 130 | return actual?.find((a) => a.name == e.name); 131 | }); 132 | 133 | assert.deepStrictEqual(subset, expected); 134 | }, 135 | ); 136 | 137 | await suite.test('has the correct params', { skip: skipIfMissingEnv('PARAMS') }, async () => { 138 | const expected = JSON.parse(process.env.PARAMS!); 139 | 140 | if (expected.containerConncurrency) { 141 | assert.deepStrictEqual( 142 | (spec as run_v1.Schema$RevisionSpec).containerConcurrency, 143 | expected.containerConncurrency, 144 | ); 145 | } 146 | 147 | if (expected.timeoutSeconds) { 148 | assert.deepStrictEqual(spec.timeoutSeconds, expected.timeoutSeconds); 149 | } 150 | 151 | const limits = spec.containers?.at(0)?.resources?.limits; 152 | if (expected.cpu) { 153 | assert.deepStrictEqual(limits?.cpu, expected.cpu.toString()); 154 | } 155 | 156 | if (expected.memory) { 157 | assert.deepStrictEqual(limits?.memory, expected.memory); 158 | } 159 | }); 160 | 161 | await suite.test( 162 | 'has the correct annotations', 163 | { skip: skipIfMissingEnv('ANNOTATIONS') }, 164 | async () => { 165 | const expected = JSON.parse(process.env.ANNOTATIONS!) as Record; 166 | const actual = metadata.annotations; 167 | 168 | const subset = Object.assign( 169 | {}, 170 | ...Object.keys(expected).map((k) => ({ [k]: actual?.[k] })), 171 | ); 172 | 173 | assert.deepStrictEqual(subset, expected); 174 | }, 175 | ); 176 | 177 | await suite.test('has the correct labels', { skip: skipIfMissingEnv('LABELS') }, async () => { 178 | const expected = JSON.parse(process.env.LABELS!) as Record; 179 | const actual = metadata.labels; 180 | 181 | const subset = Object.assign({}, ...Object.keys(expected).map((k) => ({ [k]: actual?.[k] }))); 182 | 183 | assert.deepStrictEqual(subset, expected); 184 | }); 185 | 186 | await suite.test('has the revision name', { skip: skipIfMissingEnv('REVISION') }, async () => { 187 | const expected = process.env.REVISION! as string; 188 | const actual = service.metadata?.name; 189 | assert.deepStrictEqual(actual, expected); 190 | }); 191 | 192 | await suite.test('has the job name', { skip: skipIfMissingEnv('JOB') }, async () => { 193 | const expected = process.env.JOB! as string; 194 | const actual = job.metadata?.name; 195 | assert.deepStrictEqual(actual, expected); 196 | }); 197 | 198 | await suite.test('has the correct tag', { skip: skipIfMissingEnv('TAG') }, async () => { 199 | const expected = process.env.TAG!; 200 | const actual = service?.spec?.traffic?.map((revision) => revision.tag); 201 | assert.deepStrictEqual(actual, expected); 202 | }); 203 | 204 | await suite.test( 205 | 'has the correct traffic', 206 | { skip: skipIfMissingEnv('TRAFFIC', 'TAG') }, 207 | async () => { 208 | const expected = process.env.TRAFFIC!; 209 | const tagged = service?.spec?.traffic?.find((revision) => { 210 | return revision.tag == process.env.TAG!; 211 | }); 212 | const percent = tagged?.percent; 213 | assert.deepStrictEqual(percent, expected); 214 | }, 215 | ); 216 | }, 217 | ); 218 | 219 | const parseEnvVars = (envVarInput: string): run_v1.Schema$EnvVar[] => { 220 | const m = JSON.parse(envVarInput) as Record; 221 | const envVars = Object.entries(m).map(([key, value]) => { 222 | return { name: key, value: value }; 223 | }); 224 | return envVars; 225 | }; 226 | -------------------------------------------------------------------------------- /tests/fixtures/env_vars.txt: -------------------------------------------------------------------------------- 1 | TEXT_FOO=bar 2 | TEXT_ZIP=zap\,with|separators\,and&stuff 3 | -------------------------------------------------------------------------------- /tests/fixtures/job.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 'run.googleapis.com/v1' 2 | kind: 'Job' 3 | metadata: 4 | name: 'job' 5 | labels: 6 | cloud.googleapis.com/location: 'us-east1' 7 | spec: 8 | template: 9 | metadata: 10 | annotations: 11 | run.googleapis.com/execution-environment: 'gen2' 12 | spec: 13 | parallelism: 1 14 | taskCount: 1 15 | template: 16 | spec: 17 | containers: 18 | - image: 'gcr.io/cloudrun/hello' 19 | imagePullPolicy: 'Always' 20 | resources: 21 | limits: 22 | cpu: '1000m' 23 | memory: '512Mi' 24 | maxRetries: 0 25 | timeoutSeconds: '3600' 26 | -------------------------------------------------------------------------------- /tests/fixtures/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 'serving.knative.dev/v1' 2 | kind: 'Service' 3 | metadata: 4 | name: 'run-full-yaml' 5 | spec: 6 | template: 7 | metadata: 8 | labels: 9 | test_label: 'test_value' 10 | annotations: 11 | run.googleapis.com/cloudsql-instances: 'test-project:us-central1:my-test-instance' 12 | spec: 13 | containerConcurrency: 20 14 | containers: 15 | - image: 'gcr.io/cloudrun/hello' 16 | ports: 17 | - containerPort: 8080 18 | resources: 19 | limits: 20 | cpu: '2' 21 | memory: '1Gi' 22 | timeoutSeconds: 300 23 | -------------------------------------------------------------------------------- /tests/unit/main.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { mock, test } from 'node:test'; 18 | import assert from 'node:assert'; 19 | 20 | import * as core from '@actions/core'; 21 | import * as exec from '@actions/exec'; 22 | import * as setupGcloud from '@google-github-actions/setup-cloud-sdk'; 23 | import { TestToolCache } from '@google-github-actions/setup-cloud-sdk'; 24 | 25 | import { assertMembers } from '@google-github-actions/actions-utils'; 26 | 27 | import { run } from '../../src/main'; 28 | 29 | const fakeInputs: { [key: string]: string } = { 30 | image: 'gcr.io/cloudrun/hello', 31 | project_id: 'test', 32 | }; 33 | 34 | const defaultMocks = ( 35 | m: typeof mock, 36 | overrideInputs?: Record, 37 | ): Record => { 38 | const inputs = Object.assign({}, fakeInputs, overrideInputs); 39 | return { 40 | setFailed: m.method(core, 'setFailed', (msg: string) => { 41 | throw new Error(msg); 42 | }), 43 | getBooleanInput: m.method(core, 'getBooleanInput', (name: string) => { 44 | return !!inputs[name]; 45 | }), 46 | getMultilineInput: m.method(core, 'getMultilineInput', (name: string) => { 47 | return inputs[name]; 48 | }), 49 | getInput: m.method(core, 'getInput', (name: string) => { 50 | return inputs[name]; 51 | }), 52 | getExecOutput: m.method(exec, 'getExecOutput', () => { 53 | return { exitCode: 0, stderr: '', stdout: '{}' }; 54 | }), 55 | 56 | authenticateGcloudSDK: m.method(setupGcloud, 'authenticateGcloudSDK', () => {}), 57 | isAuthenticated: m.method(setupGcloud, 'isAuthenticated', () => {}), 58 | isInstalled: m.method(setupGcloud, 'isInstalled', () => { 59 | return true; 60 | }), 61 | installGcloudSDK: m.method(setupGcloud, 'installGcloudSDK', async () => { 62 | return '1.2.3'; 63 | }), 64 | installComponent: m.method(setupGcloud, 'installComponent', () => {}), 65 | setProject: m.method(setupGcloud, 'setProject', () => {}), 66 | getLatestGcloudSDKVersion: m.method(setupGcloud, 'getLatestGcloudSDKVersion', () => { 67 | return '1.2.3'; 68 | }), 69 | }; 70 | }; 71 | 72 | test('#run', { concurrency: true }, async (suite) => { 73 | const originalEnv = Object.assign({}, process.env); 74 | 75 | suite.before(() => { 76 | suite.mock.method(core, 'debug', () => {}); 77 | suite.mock.method(core, 'info', () => {}); 78 | suite.mock.method(core, 'warning', () => {}); 79 | suite.mock.method(core, 'setOutput', () => {}); 80 | suite.mock.method(core, 'setSecret', () => {}); 81 | suite.mock.method(core, 'group', () => {}); 82 | suite.mock.method(core, 'startGroup', () => {}); 83 | suite.mock.method(core, 'endGroup', () => {}); 84 | suite.mock.method(core, 'addPath', () => {}); 85 | suite.mock.method(core, 'exportVariable', () => {}); 86 | }); 87 | 88 | suite.beforeEach(async () => { 89 | await TestToolCache.start(); 90 | }); 91 | 92 | suite.afterEach(async () => { 93 | process.env = originalEnv; 94 | await TestToolCache.stop(); 95 | }); 96 | 97 | await suite.test('sets the project ID', async (t) => { 98 | const mocks = defaultMocks(t.mock, { 99 | project_id: 'my-test-project', 100 | service: 'my-test-service', 101 | }); 102 | await run(); 103 | 104 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 105 | assertMembers(args, ['--project', 'my-test-project']); 106 | }); 107 | 108 | await suite.test('sets a single region', async (t) => { 109 | const mocks = defaultMocks(t.mock, { 110 | region: 'us-central1', 111 | service: 'my-test-service', 112 | }); 113 | await run(); 114 | 115 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 116 | assertMembers(args, ['--region', 'us-central1']); 117 | }); 118 | 119 | await suite.test('sets a multiple regions', async (t) => { 120 | const mocks = defaultMocks(t.mock, { 121 | region: 'us-central1, us-east1', 122 | service: 'my-test-service', 123 | }); 124 | await run(); 125 | 126 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 127 | assertMembers(args, ['--region', 'us-central1,us-east1']); 128 | }); 129 | 130 | await suite.test('installs the gcloud SDK if it is not already installed', async (t) => { 131 | const mocks = defaultMocks(t.mock, { 132 | service: 'my-test-service', 133 | }); 134 | t.mock.method(setupGcloud, 'isInstalled', () => { 135 | return false; 136 | }); 137 | 138 | await run(); 139 | 140 | assert.deepStrictEqual(mocks.installGcloudSDK.mock.callCount(), 1); 141 | }); 142 | 143 | await suite.test('uses the cached gcloud SDK if it was already installed', async (t) => { 144 | const mocks = defaultMocks(t.mock, { 145 | service: 'my-test-service', 146 | }); 147 | t.mock.method(setupGcloud, 'isInstalled', () => { 148 | return true; 149 | }); 150 | 151 | await run(); 152 | 153 | assert.deepStrictEqual(mocks.installGcloudSDK.mock.callCount(), 0); 154 | }); 155 | 156 | await suite.test('uses default components without gcloud_component flag', async (t) => { 157 | const mocks = defaultMocks(t.mock, { 158 | service: 'my-test-service', 159 | }); 160 | 161 | await run(); 162 | 163 | assert.deepStrictEqual(mocks.installComponent.mock.callCount(), 0); 164 | }); 165 | 166 | await suite.test('throws error with invalid gcloud component flag', async (t) => { 167 | defaultMocks(t.mock, { 168 | service: 'my-test-service', 169 | gcloud_component: 'wrong_value', 170 | }); 171 | 172 | await assert.rejects( 173 | async () => { 174 | await run(); 175 | }, 176 | { message: /invalid input received for gcloud_component: wrong_value/ }, 177 | ); 178 | }); 179 | 180 | await suite.test('installs alpha component with alpha flag', async (t) => { 181 | const mocks = defaultMocks(t.mock, { 182 | service: 'my-test-service', 183 | gcloud_component: 'alpha', 184 | }); 185 | 186 | await run(); 187 | 188 | const args = mocks.installComponent.mock.calls?.at(0).arguments?.at(0); 189 | assert.deepStrictEqual(args, 'alpha'); 190 | }); 191 | 192 | await suite.test('installs alpha component with beta flag', async (t) => { 193 | const mocks = defaultMocks(t.mock, { 194 | service: 'my-test-service', 195 | gcloud_component: 'beta', 196 | }); 197 | 198 | await run(); 199 | 200 | const args = mocks.installComponent.mock.calls?.at(0).arguments?.at(0); 201 | assert.deepStrictEqual(args, 'beta'); 202 | }); 203 | 204 | await suite.test('merges envvars', async (t) => { 205 | const mocks = defaultMocks(t.mock, { 206 | service: 'my-test-service', 207 | env_vars: 'FOO=BAR', 208 | }); 209 | 210 | await run(); 211 | 212 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 213 | const envVars = splitKV(args.at(args.indexOf('--update-env-vars') + 1)); 214 | assert.deepStrictEqual(envVars, { FOO: 'BAR' }); 215 | }); 216 | 217 | await suite.test('overwrites envvars', async (t) => { 218 | const mocks = defaultMocks(t.mock, { 219 | service: 'my-test-service', 220 | env_vars: 'FOO=BAR', 221 | env_vars_update_strategy: 'overwrite', 222 | }); 223 | 224 | await run(); 225 | 226 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 227 | const envVars = splitKV(args.at(args.indexOf('--set-env-vars') + 1)); 228 | assert.deepStrictEqual(envVars, { FOO: 'BAR' }); 229 | }); 230 | 231 | await suite.test('merges secrets', async (t) => { 232 | const mocks = defaultMocks(t.mock, { 233 | service: 'my-test-service', 234 | secrets: 'FOO=bar:latest', 235 | }); 236 | 237 | await run(); 238 | 239 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 240 | const envVars = splitKV(args.at(args.indexOf('--update-secrets') + 1)); 241 | assert.deepStrictEqual(envVars, { FOO: 'bar:latest' }); 242 | }); 243 | 244 | await suite.test('overwrites secrets', async (t) => { 245 | const mocks = defaultMocks(t.mock, { 246 | service: 'my-test-service', 247 | secrets: 'FOO=bar:latest', 248 | secrets_update_strategy: 'overwrite', 249 | }); 250 | 251 | await run(); 252 | 253 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 254 | const envVars = splitKV(args.at(args.indexOf('--set-secrets') + 1)); 255 | assert.deepStrictEqual(envVars, { FOO: 'bar:latest' }); 256 | }); 257 | 258 | await suite.test('sets labels', async (t) => { 259 | const mocks = defaultMocks(t.mock, { 260 | service: 'my-test-service', 261 | labels: 'foo=bar,zip=zap', 262 | }); 263 | 264 | process.env.GITHUB_SHA = 'abcdef123456'; 265 | 266 | await run(); 267 | 268 | const expectedLabels = { 269 | 'managed-by': 'github-actions', 270 | 'commit-sha': 'abcdef123456', 271 | 'foo': 'bar', 272 | 'zip': 'zap', 273 | }; 274 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 275 | const labels = splitKV(args.at(args.indexOf('--update-labels') + 1)); 276 | assert.deepStrictEqual(labels, expectedLabels); 277 | }); 278 | 279 | await suite.test('skips default labels', async (t) => { 280 | const mocks = defaultMocks(t.mock, { 281 | service: 'my-test-service', 282 | skip_default_labels: 'true', 283 | labels: 'foo=bar,zip=zap', 284 | }); 285 | 286 | await run(); 287 | 288 | const expectedLabels = { 289 | foo: 'bar', 290 | zip: 'zap', 291 | }; 292 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 293 | const labels = splitKV(args.at(args.indexOf('--update-labels') + 1)); 294 | assert.deepStrictEqual(labels, expectedLabels); 295 | }); 296 | 297 | await suite.test('overwrites default labels', async (t) => { 298 | const mocks = defaultMocks(t.mock, { 299 | service: 'my-test-service', 300 | labels: 'commit-sha=custom-value', 301 | }); 302 | process.env.GITHUB_SHA = 'abcdef123456'; 303 | 304 | await run(); 305 | 306 | const expectedLabels = { 307 | 'managed-by': 'github-actions', 308 | 'commit-sha': 'custom-value', 309 | }; 310 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 311 | const labels = splitKV(args.at(args.indexOf('--update-labels') + 1)); 312 | assert.deepStrictEqual(labels, expectedLabels); 313 | }); 314 | 315 | await suite.test('sets source if given', async (t) => { 316 | const mocks = defaultMocks(t.mock, { 317 | service: 'my-test-service', 318 | source: 'example-app', 319 | image: '', 320 | }); 321 | 322 | await run(); 323 | 324 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 325 | assertMembers(args, ['--source', 'example-app']); 326 | }); 327 | 328 | await suite.test('sets service metadata if given', async (t) => { 329 | const mocks = defaultMocks(t.mock, { 330 | metadata: 'tests/fixtures/service.yaml', 331 | image: '', 332 | }); 333 | 334 | await run(); 335 | 336 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 337 | assertMembers(args, ['services', 'replace']); 338 | }); 339 | 340 | await suite.test('errors if metadata is given and the service names do not match', async (t) => { 341 | defaultMocks(t.mock, { 342 | metadata: 'tests/fixtures/service.yaml', 343 | service: 'not-a-match', 344 | }); 345 | 346 | await assert.rejects( 347 | async () => { 348 | await run(); 349 | }, 350 | { message: /does not match/ }, 351 | ); 352 | }); 353 | 354 | await suite.test('does not error if metadata is given and the service names match', async (t) => { 355 | defaultMocks(t.mock, { 356 | metadata: 'tests/fixtures/service.yaml', 357 | service: 'run-full-yaml', 358 | }); 359 | 360 | await assert.doesNotReject(async () => { 361 | await run(); 362 | }); 363 | }); 364 | 365 | await suite.test('sets job metadata if given', async (t) => { 366 | const mocks = defaultMocks(t.mock, { 367 | metadata: 'tests/fixtures/job.yaml', 368 | image: '', 369 | }); 370 | 371 | await run(); 372 | 373 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 374 | assertMembers(args, ['jobs', 'replace']); 375 | }); 376 | 377 | await suite.test('sets timeout if given', async (t) => { 378 | const mocks = defaultMocks(t.mock, { 379 | service: 'my-test-service', 380 | timeout: '55m12s', 381 | }); 382 | 383 | await run(); 384 | 385 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 386 | assertMembers(args, ['--timeout', '55m12s']); 387 | }); 388 | 389 | await suite.test('sets tag if given', async (t) => { 390 | const mocks = defaultMocks(t.mock, { 391 | service: 'my-test-service', 392 | tag: 'test', 393 | }); 394 | 395 | await run(); 396 | 397 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 398 | assertMembers(args, ['--tag', 'test']); 399 | }); 400 | 401 | await suite.test('sets additional flags on the deploy command', async (t) => { 402 | const mocks = defaultMocks(t.mock, { 403 | service: 'my-test-service', 404 | flags: '--arg1=1 --arg2=2', 405 | }); 406 | 407 | await run(); 408 | 409 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 410 | assertMembers(args, ['--arg1', '1', '--arg2', '2']); 411 | }); 412 | 413 | await suite.test('sets tag traffic if given', async (t) => { 414 | const mocks = defaultMocks(t.mock, { 415 | service: 'my-test-service', 416 | tag_traffic: 'TEST=100', 417 | }); 418 | 419 | await run(); 420 | 421 | const deployArgs = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 422 | assertMembers(deployArgs, ['run', 'deploy', 'my-test-service']); 423 | 424 | const updateTrafficArgs = mocks.getExecOutput.mock.calls?.at(1)?.arguments?.at(1); 425 | assertMembers(updateTrafficArgs, ['--to-tags', 'TEST=100']); 426 | }); 427 | 428 | await suite.test('fails if tag traffic and revision traffic are provided', async (t) => { 429 | defaultMocks(t.mock, { 430 | service: 'my-test-service', 431 | revision_traffic: 'TEST=100', 432 | tag_traffic: 'TEST=100', 433 | }); 434 | 435 | await assert.rejects( 436 | async () => { 437 | await run(); 438 | }, 439 | { message: /only one of `revision_traffic` or `tag_traffic` inputs can be set/ }, 440 | ); 441 | }); 442 | 443 | await suite.test('fails if service is not provided with tag traffic', async (t) => { 444 | defaultMocks(t.mock, { 445 | service: '', 446 | tag_traffic: 'TEST=100', 447 | }); 448 | 449 | await assert.rejects( 450 | async () => { 451 | await run(); 452 | }, 453 | { message: /no service name set/ }, 454 | ); 455 | }); 456 | 457 | await suite.test('sets revision traffic if given', async (t) => { 458 | const mocks = defaultMocks(t.mock, { 459 | service: 'my-test-service', 460 | revision_traffic: 'TEST=100', 461 | }); 462 | 463 | await run(); 464 | 465 | const deployArgs = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 466 | assertMembers(deployArgs, ['run', 'deploy', 'my-test-service']); 467 | 468 | const updateTrafficArgs = mocks.getExecOutput.mock.calls?.at(1)?.arguments?.at(1); 469 | assertMembers(updateTrafficArgs, ['--to-revisions', 'TEST=100']); 470 | }); 471 | 472 | await suite.test('sets additional flags on the update-traffic command', async (t) => { 473 | const mocks = defaultMocks(t.mock, { 474 | service: 'my-test-service', 475 | tag_traffic: 'test', 476 | update_traffic_flags: '--arg1=1 --arg2=2', 477 | }); 478 | 479 | await run(); 480 | 481 | const args = mocks.getExecOutput.mock.calls?.at(1)?.arguments?.at(1); 482 | assertMembers(args, ['--arg1', '1', '--arg2', '2']); 483 | }); 484 | 485 | await suite.test('fails if service is not provided with revision traffic', async (t) => { 486 | defaultMocks(t.mock, { 487 | service: '', 488 | revision_traffic: 'TEST=100', 489 | }); 490 | 491 | await assert.rejects( 492 | async () => { 493 | await run(); 494 | }, 495 | { message: /no service name set/ }, 496 | ); 497 | }); 498 | 499 | await suite.test('fails if job and service are both specified', async (t) => { 500 | defaultMocks(t.mock, { 501 | service: 'my-test-service', 502 | job: 'my-test-job', 503 | }); 504 | 505 | await assert.rejects( 506 | async () => { 507 | await run(); 508 | }, 509 | { message: /only one of `service` or `job` inputs can be set/ }, 510 | ); 511 | }); 512 | 513 | await suite.test('deploys a job if job is specified', async (t) => { 514 | const mocks = defaultMocks(t.mock, { 515 | job: 'my-test-job', 516 | }); 517 | 518 | await run(); 519 | 520 | const args = mocks.getExecOutput.mock.calls?.at(0)?.arguments?.at(1); 521 | assertMembers(args, ['run', 'jobs', 'deploy', 'my-test-job']); 522 | }); 523 | }); 524 | 525 | const splitKV = (s: string): Record => { 526 | const delim = s.match(/\^(.+)\^/i); 527 | if (!delim || delim.length === 0) { 528 | throw new Error(`Invalid delimiter: ${s}`); 529 | } 530 | 531 | const parts = s.slice(delim[0].length).split(delim[1]); 532 | return Object.fromEntries(parts.map((p) => p.split('='))); 533 | }; 534 | -------------------------------------------------------------------------------- /tests/unit/output-parser.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { test } from 'node:test'; 18 | import assert from 'node:assert'; 19 | 20 | import { parseUpdateTrafficResponse, parseDeployResponse } from '../../src/output-parser'; 21 | 22 | test('#parseUpdateTrafficResponse', { concurrency: true }, async (suite) => { 23 | const cases = [ 24 | { 25 | name: 'parses update traffic outputs', 26 | stdout: ` 27 | [ 28 | { 29 | "displayPercent": "100%", 30 | "displayRevisionId": "LATEST (currently test-basic-yaml-00007-leg)", 31 | "displayTags": "", 32 | "key": "LATEST", 33 | "latestRevision": true, 34 | "revisionName": "test-basic-yaml-00007-leg", 35 | "serviceUrl": "https://test-basic-yaml-4goqgbaxqq-uc.a.run.app", 36 | "specPercent": "100", 37 | "specTags": "-", 38 | "statusPercent": "100", 39 | "statusTags": "-", 40 | "tags": [], 41 | "urls": [] 42 | } 43 | ] 44 | `, 45 | expected: { url: 'https://test-basic-yaml-4goqgbaxqq-uc.a.run.app' }, 46 | }, 47 | { 48 | name: 'parses update traffic with single tag', 49 | stdout: ` 50 | [ 51 | { 52 | "displayPercent": "0%", 53 | "displayRevisionId": "test-basic-yaml-00005-yus", 54 | "displayTags": "my-tag-1", 55 | "key": "test-basic-yaml-00005-yus", 56 | "latestRevision": false, 57 | "revisionName": "test-basic-yaml-00005-yus", 58 | "serviceUrl": "https://test-basic-yaml-4goqgbaxqq-uc.a.run.app", 59 | "specPercent": "0", 60 | "specTags": "my-tag-1", 61 | "statusPercent": "0", 62 | "statusTags": "my-tag-1", 63 | "tags": [ 64 | { 65 | "inSpec": true, 66 | "inStatus": true, 67 | "tag": "my-tag-1", 68 | "url": "https://my-tag-1---test-basic-yaml-4goqgbaxqq-uc.a.run.app" 69 | } 70 | ], 71 | "urls": [ 72 | "https://my-tag-1---test-basic-yaml-4goqgbaxqq-uc.a.run.app" 73 | ] 74 | }, 75 | { 76 | "displayPercent": "100%", 77 | "displayRevisionId": "LATEST (currently test-basic-yaml-00007-leg)", 78 | "displayTags": "", 79 | "key": "LATEST", 80 | "latestRevision": true, 81 | "revisionName": "test-basic-yaml-00007-leg", 82 | "serviceUrl": "https://test-basic-yaml-4goqgbaxqq-uc.a.run.app", 83 | "specPercent": "100", 84 | "specTags": "-", 85 | "statusPercent": "100", 86 | "statusTags": "-", 87 | "tags": [], 88 | "urls": [] 89 | } 90 | ] 91 | `, 92 | expected: { url: 'https://my-tag-1---test-basic-yaml-4goqgbaxqq-uc.a.run.app' }, 93 | }, 94 | { 95 | name: 'parses update traffic with multiple tags', 96 | stdout: ` 97 | [ 98 | { 99 | "displayPercent": "20%", 100 | "displayRevisionId": "test-basic-yaml-00005-yus", 101 | "displayTags": "my-tag-1, my-tag-2", 102 | "key": "test-basic-yaml-00005-yus", 103 | "latestRevision": false, 104 | "revisionName": "test-basic-yaml-00005-yus", 105 | "serviceUrl": "https://test-basic-yaml-4goqgbaxqq-uc.a.run.app", 106 | "specPercent": "20", 107 | "specTags": "my-tag-1, my-tag-2", 108 | "statusPercent": "20", 109 | "statusTags": "my-tag-1, my-tag-2", 110 | "tags": [ 111 | { 112 | "inSpec": true, 113 | "inStatus": true, 114 | "tag": "my-tag-1", 115 | "url": "https://my-tag-1---test-basic-yaml-4goqgbaxqq-uc.a.run.app" 116 | }, 117 | { 118 | "inSpec": true, 119 | "inStatus": true, 120 | "tag": "my-tag-2", 121 | "url": "https://my-tag-2---test-basic-yaml-4goqgbaxqq-uc.a.run.app" 122 | } 123 | ], 124 | "urls": [ 125 | "https://my-tag-1---test-basic-yaml-4goqgbaxqq-uc.a.run.app", 126 | "https://my-tag-2---test-basic-yaml-4goqgbaxqq-uc.a.run.app" 127 | ] 128 | }, 129 | { 130 | "displayPercent": "40%", 131 | "displayRevisionId": "test-basic-yaml-00006-juz", 132 | "displayTags": "another-2, test-2", 133 | "key": "test-basic-yaml-00006-juz", 134 | "latestRevision": false, 135 | "revisionName": "test-basic-yaml-00006-juz", 136 | "serviceUrl": "https://test-basic-yaml-4goqgbaxqq-uc.a.run.app", 137 | "specPercent": "40", 138 | "specTags": "another-2, test-2", 139 | "statusPercent": "40", 140 | "statusTags": "another-2, test-2", 141 | "tags": [ 142 | { 143 | "inSpec": true, 144 | "inStatus": true, 145 | "tag": "another-2", 146 | "url": "https://another-2---test-basic-yaml-4goqgbaxqq-uc.a.run.app" 147 | }, 148 | { 149 | "inSpec": true, 150 | "inStatus": true, 151 | "tag": "test-2", 152 | "url": "https://test-2---test-basic-yaml-4goqgbaxqq-uc.a.run.app" 153 | } 154 | ], 155 | "urls": [ 156 | "https://another-2---test-basic-yaml-4goqgbaxqq-uc.a.run.app", 157 | "https://test-2---test-basic-yaml-4goqgbaxqq-uc.a.run.app" 158 | ] 159 | }, 160 | { 161 | "displayPercent": "40%", 162 | "displayRevisionId": "test-basic-yaml-00007-leg", 163 | "displayTags": "another-1, test-1", 164 | "key": "test-basic-yaml-00007-leg", 165 | "latestRevision": false, 166 | "revisionName": "test-basic-yaml-00007-leg", 167 | "serviceUrl": "https://test-basic-yaml-4goqgbaxqq-uc.a.run.app", 168 | "specPercent": "40", 169 | "specTags": "another-1, test-1", 170 | "statusPercent": "40", 171 | "statusTags": "another-1, test-1", 172 | "tags": [ 173 | { 174 | "inSpec": true, 175 | "inStatus": true, 176 | "tag": "another-1", 177 | "url": "https://another-1---test-basic-yaml-4goqgbaxqq-uc.a.run.app" 178 | }, 179 | { 180 | "inSpec": true, 181 | "inStatus": true, 182 | "tag": "test-1", 183 | "url": "https://test-1---test-basic-yaml-4goqgbaxqq-uc.a.run.app" 184 | } 185 | ], 186 | "urls": [ 187 | "https://another-1---test-basic-yaml-4goqgbaxqq-uc.a.run.app", 188 | "https://test-1---test-basic-yaml-4goqgbaxqq-uc.a.run.app" 189 | ] 190 | } 191 | ] 192 | `, 193 | expected: { url: 'https://my-tag-1---test-basic-yaml-4goqgbaxqq-uc.a.run.app' }, 194 | }, 195 | { 196 | name: 'handles empty stdout', 197 | stdout: '', 198 | expected: {}, 199 | }, 200 | { 201 | name: 'handles empty array from stdout', 202 | stdout: '[]', 203 | expected: {}, 204 | }, 205 | { 206 | name: 'handles empty object from stdout', 207 | stdout: '{}', 208 | expected: {}, 209 | }, 210 | { 211 | name: 'handles invalid text from stdout', 212 | stdout: 'Some text to fail', 213 | error: `failed to parse update traffic response: unexpected token 'S', "Some text to fail" is not valid JSON, stdout: Some text to fail`, 214 | }, 215 | ]; 216 | 217 | for await (const tc of cases) { 218 | await suite.test(tc.name, async () => { 219 | if (tc.error) { 220 | assert.throws( 221 | () => { 222 | parseUpdateTrafficResponse(tc.stdout); 223 | }, 224 | { message: tc.error }, 225 | ); 226 | } else { 227 | const actual = parseUpdateTrafficResponse(tc.stdout); 228 | assert.deepStrictEqual(actual, tc.expected); 229 | } 230 | }); 231 | } 232 | }); 233 | 234 | test('#parseDeployResponse', { concurrency: true }, async (suite) => { 235 | const cases = [ 236 | { 237 | name: 'parses deploy outputs', 238 | stdout: ` 239 | { 240 | "apiVersion": "serving.knative.dev/v1", 241 | "kind": "Service", 242 | "metadata": { 243 | "annotations": { 244 | "client.knative.dev/user-image": "image-name:1.0.0", 245 | "run.googleapis.com/client-name": "gcloud", 246 | "run.googleapis.com/client-version": "368.0.0", 247 | "run.googleapis.com/ingress": "internal", 248 | "run.googleapis.com/ingress-status": "internal", 249 | "serving.knative.dev/creator": "creator@domain.com", 250 | "serving.knative.dev/lastModifier": "creator@domain.com" 251 | }, 252 | "creationTimestamp": "2022-01-25T21:10:53.714758Z", 253 | "generation": 2, 254 | "labels": { 255 | "cloud.googleapis.com/location": "us-central1" 256 | }, 257 | "name": "hello", 258 | "namespace": "392520182231", 259 | "resourceVersion": "AAXWbpADof4", 260 | "selfLink": "/apis/serving.knative.dev/v1/namespaces/392520182231/services/hello", 261 | "uid": "a79b8fc9-f05b-468c-809a-7bb28988fb00" 262 | }, 263 | "spec": { 264 | "template": { 265 | "metadata": { 266 | "annotations": { 267 | "autoscaling.knative.dev/maxScale": "2", 268 | "client.knative.dev/user-image": "image-name:1.0.0", 269 | "run.googleapis.com/client-name": "gcloud", 270 | "run.googleapis.com/client-version": "368.0.0" 271 | }, 272 | "name": "hello-00002-hex" 273 | }, 274 | "spec": { 275 | "containerConcurrency": 80, 276 | "containers": [ 277 | { 278 | "image": "image-name:1.0.0", 279 | "ports": [ 280 | { 281 | "containerPort": 8080, 282 | "name": "http1" 283 | } 284 | ], 285 | "resources": { 286 | "limits": { 287 | "cpu": "1000m", 288 | "memory": "512Mi" 289 | } 290 | } 291 | } 292 | ], 293 | "serviceAccountName": "000000000-compute@developer.gserviceaccount.com", 294 | "timeoutSeconds": 300 295 | } 296 | }, 297 | "traffic": [ 298 | { 299 | "latestRevision": true, 300 | "percent": 100 301 | } 302 | ] 303 | }, 304 | "status": { 305 | "address": { 306 | "url": "https://action-test-cy7cdwrvha-uc.a.run.app" 307 | }, 308 | "conditions": [ 309 | { 310 | "lastTransitionTime": "2022-01-25T21:13:54.457086Z", 311 | "status": "True", 312 | "type": "Ready" 313 | }, 314 | { 315 | "lastTransitionTime": "2022-01-25T21:13:48.190586Z", 316 | "status": "True", 317 | "type": "ConfigurationsReady" 318 | }, 319 | { 320 | "lastTransitionTime": "2022-01-25T21:13:54.457086Z", 321 | "status": "True", 322 | "type": "RoutesReady" 323 | } 324 | ], 325 | "latestCreatedRevisionName": "hello-00002-hex", 326 | "latestReadyRevisionName": "hello-00002-hex", 327 | "observedGeneration": 2, 328 | "traffic": [ 329 | { 330 | "latestRevision": true, 331 | "percent": 100, 332 | "revisionName": "hello-00002-hex" 333 | } 334 | ], 335 | "url": "https://action-test-cy7cdwrvha-uc.a.run.app" 336 | } 337 | } 338 | `, 339 | expected: { url: 'https://action-test-cy7cdwrvha-uc.a.run.app' }, 340 | }, 341 | { 342 | name: 'parses deploy outputs with tag input', 343 | parseInputs: { tag: 'test' }, 344 | stdout: ` 345 | { 346 | "apiVersion": "serving.knative.dev/v1", 347 | "kind": "Service", 348 | "metadata": { 349 | "annotations": { 350 | "client.knative.dev/user-image": "us-docker.pkg.dev/cloudrun/container/hello@sha256:1595248959b1eaac7f793dfcab2adaecf9c14fdf1cc2b60d20539c6b22fd8e4a", 351 | "run.googleapis.com/client-name": "gcloud", 352 | "run.googleapis.com/client-version": "368.0.0", 353 | "run.googleapis.com/ingress": "internal", 354 | "run.googleapis.com/ingress-status": "internal", 355 | "serving.knative.dev/creator": "verbanicm@google.com", 356 | "serving.knative.dev/lastModifier": "verbanicm@google.com" 357 | }, 358 | "creationTimestamp": "2022-01-25T21:10:53.714758Z", 359 | "generation": 9, 360 | "labels": { 361 | "cloud.googleapis.com/location": "us-central1" 362 | }, 363 | "name": "hello", 364 | "namespace": "392520182231", 365 | "resourceVersion": "AAXWb88QKzQ", 366 | "selfLink": "/apis/serving.knative.dev/v1/namespaces/392520182231/services/hello", 367 | "uid": "a79b8fc9-f05b-468c-809a-7bb28988fb00" 368 | }, 369 | "spec": { 370 | "template": { 371 | "metadata": { 372 | "annotations": { 373 | "autoscaling.knative.dev/maxScale": "2", 374 | "client.knative.dev/user-image": "us-docker.pkg.dev/cloudrun/container/hello@sha256:1595248959b1eaac7f793dfcab2adaecf9c14fdf1cc2b60d20539c6b22fd8e4a", 375 | "run.googleapis.com/client-name": "gcloud", 376 | "run.googleapis.com/client-version": "368.0.0" 377 | }, 378 | "name": "hello-suffix" 379 | }, 380 | "spec": { 381 | "containerConcurrency": 80, 382 | "containers": [ 383 | { 384 | "image": "us-docker.pkg.dev/cloudrun/container/hello@sha256:1595248959b1eaac7f793dfcab2adaecf9c14fdf1cc2b60d20539c6b22fd8e4a", 385 | "ports": [ 386 | { 387 | "containerPort": 8080, 388 | "name": "http1" 389 | } 390 | ], 391 | "resources": { 392 | "limits": { 393 | "cpu": "1000m", 394 | "memory": "512Mi" 395 | } 396 | } 397 | } 398 | ], 399 | "serviceAccountName": "392520182231-compute@developer.gserviceaccount.com", 400 | "timeoutSeconds": 300 401 | } 402 | }, 403 | "traffic": [ 404 | { 405 | "percent": 100, 406 | "revisionName": "hello-00007-jec" 407 | }, 408 | { 409 | "revisionName": "hello-00005-zay", 410 | "tag": "test" 411 | }, 412 | { 413 | "revisionName": "hello-00007-jec", 414 | "tag": "another" 415 | } 416 | ] 417 | }, 418 | "status": { 419 | "address": { 420 | "url": "https://hello-4goqgbaxqq-uc.a.run.app" 421 | }, 422 | "conditions": [ 423 | { 424 | "lastTransitionTime": "2022-01-25T22:43:07.210548Z", 425 | "status": "True", 426 | "type": "Ready" 427 | }, 428 | { 429 | "lastTransitionTime": "2022-01-25T22:43:07.210548Z", 430 | "status": "True", 431 | "type": "ConfigurationsReady" 432 | }, 433 | { 434 | "lastTransitionTime": "2022-01-25T22:41:16.384919Z", 435 | "status": "True", 436 | "type": "RoutesReady" 437 | } 438 | ], 439 | "latestCreatedRevisionName": "hello-suffix", 440 | "latestReadyRevisionName": "hello-suffix", 441 | "observedGeneration": 9, 442 | "traffic": [ 443 | { 444 | "percent": 100, 445 | "revisionName": "hello-00007-jec" 446 | }, 447 | { 448 | "revisionName": "hello-00005-zay", 449 | "tag": "test", 450 | "url": "https://test---hello-4goqgbaxqq-uc.a.run.app" 451 | }, 452 | { 453 | "revisionName": "hello-00007-jec", 454 | "tag": "another", 455 | "url": "https://another---hello-4goqgbaxqq-uc.a.run.app" 456 | } 457 | ], 458 | "url": "https://hello-4goqgbaxqq-uc.a.run.app" 459 | } 460 | } 461 | `, 462 | expected: { url: 'https://test---hello-4goqgbaxqq-uc.a.run.app' }, 463 | }, 464 | { 465 | name: 'handles empty stdout', 466 | stdout: ``, 467 | expected: {}, 468 | }, 469 | { 470 | name: 'handles empty array from stdout', 471 | stdout: `[]`, 472 | expected: {}, 473 | }, 474 | { 475 | name: 'handles empty object from stdout', 476 | stdout: `{}`, 477 | expected: {}, 478 | }, 479 | { 480 | name: 'handles invalid text from stdout', 481 | stdout: `Some text to fail`, 482 | error: `failed to parse deploy response: unexpected token 'S', "Some text to fail" is not valid JSON, stdout: Some text to fail, inputs: undefined`, 483 | }, 484 | ]; 485 | 486 | for await (const tc of cases) { 487 | await suite.test(tc.name, async () => { 488 | if (tc.error) { 489 | assert.throws( 490 | () => { 491 | parseDeployResponse(tc.stdout, tc.parseInputs); 492 | }, 493 | { message: tc.error }, 494 | ); 495 | } else { 496 | const actual = parseDeployResponse(tc.stdout, tc.parseInputs); 497 | assert.deepStrictEqual(actual, tc.expected); 498 | } 499 | }); 500 | } 501 | }); 502 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | { 17 | "compilerOptions": { 18 | "target": "es6", 19 | "module": "commonjs", 20 | "lib": [ 21 | "es6" 22 | ], 23 | "outDir": "./dist", 24 | "rootDir": "./src", 25 | "strict": true, 26 | "noImplicitAny": true, 27 | "esModuleInterop": true 28 | }, 29 | "exclude": ["dist/**/*", "node_modules", "**/*.test.ts"], 30 | } 31 | --------------------------------------------------------------------------------