├── .gitattributes ├── .github ├── renovate.json5 └── workflows │ ├── autofix.yaml │ ├── bootstrap-pull-request.yaml │ ├── create-deploy-pull-request.yaml │ ├── environment-matrix.yaml │ ├── get-service-versions.yaml │ ├── git-push-service.yaml │ ├── git-push-services-patch.yaml │ ├── open-backport-pull-request.yaml │ ├── release.yaml │ ├── resolve-aws-secret-version.yaml │ ├── substitute.yaml │ ├── template.yaml │ └── update-outdated-pull-request-branch.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── bootstrap-pull-request ├── README.md ├── action.yaml ├── jest.config.js ├── package.json ├── src │ ├── git.ts │ ├── main.ts │ ├── namespace.ts │ ├── prebuilt.ts │ ├── retry.ts │ └── run.ts ├── tests │ ├── fixtures │ │ ├── namespace.yaml │ │ └── prebuilt │ │ │ ├── applications │ │ │ ├── prebuilt--a.yaml │ │ │ └── prebuilt--b.yaml │ │ │ └── services │ │ │ ├── a │ │ │ └── generated.yaml │ │ │ └── b │ │ │ └── generated.yaml │ ├── namespace.test.ts │ └── prebuilt.test.ts └── tsconfig.json ├── create-deploy-pull-request ├── README.md ├── action.yaml ├── jest.config.js ├── package.json ├── src │ ├── branch.ts │ ├── main.ts │ ├── pull.ts │ └── run.ts ├── tests │ ├── github.ts │ └── run.test.ts └── tsconfig.json ├── environment-matrix ├── README.md ├── action.yaml ├── jest.config.js ├── package.json ├── src │ ├── deployment.ts │ ├── github.ts │ ├── main.ts │ ├── matcher.ts │ ├── rule.ts │ └── run.ts ├── tests │ ├── fixtures │ │ └── test │ ├── matcher.test.ts │ └── rule.test.ts └── tsconfig.json ├── eslint.config.mjs ├── get-service-versions ├── README.md ├── action.yaml ├── jest.config.js ├── package.json ├── src │ ├── application.ts │ ├── git.ts │ ├── main.ts │ └── run.ts ├── tests │ ├── aplication.test.ts │ └── fixtures │ │ ├── applications │ │ └── pr-12345--a.yaml │ │ ├── namespace.yaml │ │ └── services │ │ └── a │ │ └── generated.yaml └── tsconfig.json ├── git-push-service ├── README.md ├── action.yaml ├── jest.config.js ├── package.json ├── src │ ├── arrange.ts │ ├── git.ts │ ├── main.ts │ ├── pull.ts │ ├── retry.ts │ └── run.ts ├── tests │ ├── arrange.test.ts │ └── fixtures │ │ ├── a │ │ └── generated.yaml │ │ └── b │ │ └── generated.yaml └── tsconfig.json ├── git-push-services-patch ├── README.md ├── action.yaml ├── jest.config.js ├── package.json ├── src │ ├── git.ts │ ├── main.ts │ ├── patch.ts │ ├── retry.ts │ └── run.ts ├── tests │ ├── fixtures │ │ └── kustomization.yaml │ └── patch.test.ts └── tsconfig.json ├── open-backport-pull-request ├── README.md ├── action.yaml ├── jest.config.js ├── package.json ├── src │ ├── format.ts │ ├── github.ts │ ├── main.ts │ └── run.ts ├── tests │ └── format.test.ts └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── resolve-aws-secret-version ├── README.md ├── action.yaml ├── jest.config.js ├── package.json ├── src │ ├── awsSecretsManager.ts │ ├── kubernetes.ts │ ├── main.ts │ ├── resolve.ts │ └── run.ts ├── tests │ ├── awsSecretsManager.test.ts │ ├── fixtures │ │ ├── expected-with-awssecret-placeholder.yaml │ │ ├── expected-with-externalsecret-placeholder.yaml │ │ ├── input-with-awssecret-placeholder.yaml │ │ ├── input-with-externalsecret-placeholder.yaml │ │ └── input-with-no-placeholder.yaml │ ├── resolve.test.ts │ └── run.test.ts └── tsconfig.json ├── substitute ├── README.md ├── action.yaml ├── jest.config.js ├── package.json ├── src │ ├── main.ts │ └── run.ts ├── tests │ ├── fixtures │ │ ├── a │ │ │ └── generated.yaml │ │ └── b │ │ │ └── generated.yaml │ └── run.test.ts └── tsconfig.json ├── template ├── README.md ├── action.yaml ├── jest.config.js ├── package.json ├── src │ ├── main.ts │ └── run.ts ├── tests │ └── run.test.ts └── tsconfig.json ├── tsconfig.json └── update-outdated-pull-request-branch ├── README.md ├── action.yaml ├── jest.config.js ├── package.json ├── src ├── main.ts └── run.ts ├── tests └── run.test.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | "helpers:pinGitHubActionDigests", 6 | "github>int128/typescript-action-renovate-config#v1.7.0", 7 | ":automergeMinor", 8 | ":label(renovate/{{depName}})", 9 | ":reviewer(team:sre-jp)", 10 | ], 11 | // DO NOT enable platformAutomerge, 12 | // because this repository does not have a required check in the branch protection rules. 13 | "platformAutomerge": false, 14 | "packageRules": [ 15 | { 16 | "matchPaths": ["*/**"], 17 | "additionalBranchPrefix": "{{packageFileDir}}-", 18 | "commitMessageSuffix": "({{packageFileDir}})", 19 | "excludePackageNames": [ 20 | // update all action.yaml in single pull request 21 | "node", 22 | "@types/node", 23 | ], 24 | }, 25 | { 26 | // Do not update the dependencies of @actions/github. 27 | // https://github.com/quipper/monorepo-deploy-actions/pull/1362 28 | "matchPackageNames": [ 29 | "@octokit/core", 30 | "@octokit/plugin-retry", 31 | ], 32 | "enabled": false, 33 | }, 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yaml: -------------------------------------------------------------------------------- 1 | name: autofix 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '**/*.ts' 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/autofix.yaml 10 | 11 | jobs: 12 | autofix: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 10 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 18 | with: 19 | node-version: 20 20 | - run: npm install -g pnpm@latest-10 21 | - run: pnpm i 22 | - run: pnpm format 23 | - run: pnpm lint 24 | - uses: int128/update-generated-files-action@f6dc44e35ce252932e9018f1c38d1e2a4ff80e14 # v2.60.0 25 | -------------------------------------------------------------------------------- /.github/workflows/bootstrap-pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: bootstrap-pull-request 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - bootstrap-pull-request/** 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/bootstrap-pull-request.yaml 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - bootstrap-pull-request/** 15 | - '*.json' 16 | - '*.yaml' 17 | - .github/workflows/bootstrap-pull-request.yaml 18 | 19 | defaults: 20 | run: 21 | working-directory: bootstrap-pull-request 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | - run: npm install -g pnpm@latest-10 33 | - run: pnpm i 34 | - run: pnpm test 35 | 36 | e2e-test: 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 42 | with: 43 | node-version: 20 44 | - run: npm install -g pnpm@latest-10 45 | - run: pnpm i 46 | - run: pnpm build 47 | 48 | - run: | 49 | git config --global user.email 'github-actions@github.com' 50 | git config --global user.name 'github-actions' 51 | 52 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 53 | with: 54 | ref: ${{ github.head_ref }} # avoid "shallow update not allowed" error 55 | path: prebuilt-branch 56 | - name: Set up an prebuilt branch 57 | working-directory: prebuilt-branch 58 | run: | 59 | cp -av "$GITHUB_WORKSPACE/bootstrap-pull-request/tests/fixtures/prebuilt/." . 60 | git add . 61 | git commit -m "Add prebuilt branch for e2e-test of ${GITHUB_REF}" 62 | git push origin "HEAD:refs/heads/bootstrap-pull-request-e2e-prebuilt-${{ github.run_id }}" 63 | 64 | - uses: ./bootstrap-pull-request 65 | with: 66 | overlay: overlay-${{ github.run_id }} 67 | namespace: pr-${{ github.event.number }} 68 | destination-repository: ${{ github.repository }} 69 | prebuilt-branch: bootstrap-pull-request-e2e-prebuilt-${{ github.run_id }} 70 | namespace-manifest: bootstrap-pull-request/tests/fixtures/namespace.yaml 71 | substitute-variables: NAMESPACE=pr-${{ github.event.number }} 72 | 73 | # the action should be idempotent 74 | - uses: ./bootstrap-pull-request 75 | with: 76 | overlay: overlay-${{ github.run_id }} 77 | namespace: pr-${{ github.event.number }} 78 | destination-repository: ${{ github.repository }} 79 | prebuilt-branch: bootstrap-pull-request-e2e-prebuilt-${{ github.run_id }} 80 | namespace-manifest: bootstrap-pull-request/tests/fixtures/namespace.yaml 81 | substitute-variables: NAMESPACE=pr-${{ github.event.number }} 82 | 83 | - name: Clean up the namespace branch 84 | continue-on-error: true 85 | if: always() 86 | run: | 87 | git push origin --delete "refs/heads/ns/monorepo-deploy-actions/overlay-${{ github.run_id }}/pr-${{ github.event.number }}" 88 | - name: Clean up the prebuilt branch 89 | continue-on-error: true 90 | if: always() 91 | run: | 92 | git push origin --delete "refs/heads/bootstrap-pull-request-e2e-prebuilt-${{ github.run_id }}" 93 | -------------------------------------------------------------------------------- /.github/workflows/create-deploy-pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: create-deploy-pull-request 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - create-deploy-pull-request/** 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/create-deploy-pull-request.yaml 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - create-deploy-pull-request/** 15 | - '*.json' 16 | - '*.yaml' 17 | - .github/workflows/create-deploy-pull-request.yaml 18 | 19 | defaults: 20 | run: 21 | working-directory: create-deploy-pull-request 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | - run: npm install -g pnpm@latest-10 33 | - run: pnpm i 34 | - run: pnpm test 35 | 36 | e2e-test: 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 42 | with: 43 | node-version: 20 44 | - run: npm install -g pnpm@latest-10 45 | - run: pnpm i 46 | - run: pnpm build 47 | - uses: ./create-deploy-pull-request 48 | with: 49 | head-branch: main 50 | base-branch: create-deploy-pull-request--e2e-test--${{ github.run_number }} 51 | title: E2E test of create-deploy-pull-request 52 | body: E2E test of create-deploy-pull-request 53 | - if: always() 54 | run: git push origin --delete 'refs/heads/create-deploy-pull-request--e2e-test--${{ github.run_number }}' 55 | continue-on-error: true 56 | -------------------------------------------------------------------------------- /.github/workflows/environment-matrix.yaml: -------------------------------------------------------------------------------- 1 | name: environment-matrix 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - environment-matrix/** 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/environment-matrix.yaml 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - environment-matrix/** 15 | - '*.json' 16 | - '*.yaml' 17 | - .github/workflows/environment-matrix.yaml 18 | 19 | defaults: 20 | run: 21 | working-directory: environment-matrix 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | - run: npm install -g pnpm@latest-10 33 | - run: pnpm i 34 | - run: pnpm test 35 | 36 | e2e-test: 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | outputs: 40 | environments: ${{ steps.environment-matrix.outputs.json }} 41 | steps: 42 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 43 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 44 | with: 45 | node-version: 20 46 | - run: npm install -g pnpm@latest-10 47 | - run: pnpm i 48 | - run: pnpm build 49 | - uses: ./environment-matrix 50 | id: environment-matrix 51 | with: 52 | rules: | 53 | - pull_request: 54 | base: '**' 55 | head: '**' 56 | environments: 57 | - github-deployment: 58 | environment: pr-${{ github.event.pull_request.number }}/example 59 | outputs: 60 | overlay: pr 61 | namespace: pr-${{ github.event.pull_request.number }} 62 | - push: 63 | ref: refs/heads/main 64 | environments: 65 | - outputs: 66 | overlay: development 67 | namespace: development 68 | 69 | e2e-test-matrix: 70 | needs: e2e-test 71 | runs-on: ubuntu-latest 72 | timeout-minutes: 3 73 | defaults: 74 | run: 75 | working-directory: . # run without actions/checkout 76 | strategy: 77 | fail-fast: true 78 | matrix: 79 | environment: ${{ fromJSON(needs.e2e-test.outputs.environments) }} 80 | steps: 81 | - run: echo 'overlay=${{ matrix.environment.overlay }}' 82 | - run: echo 'namespace=${{ matrix.environment.namespace }}' 83 | - run: echo 'github-deployment-url=${{ matrix.environment.github-deployment-url }}' 84 | -------------------------------------------------------------------------------- /.github/workflows/get-service-versions.yaml: -------------------------------------------------------------------------------- 1 | name: get-service-versions 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - get-service-versions/** 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/get-service-versions.yaml 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - get-service-versions/** 15 | - '*.json' 16 | - '*.yaml' 17 | - .github/workflows/get-service-versions.yaml 18 | 19 | defaults: 20 | run: 21 | working-directory: get-service-versions 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | - run: npm install -g pnpm@latest-10 33 | - run: pnpm i 34 | - run: pnpm test 35 | 36 | e2e-test: 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 42 | with: 43 | node-version: 20 44 | - run: npm install -g pnpm@latest-10 45 | - run: pnpm i 46 | - run: pnpm build 47 | 48 | - run: | 49 | git config --global user.email 'github-actions@github.com' 50 | git config --global user.name 'github-actions' 51 | 52 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 53 | with: 54 | ref: ${{ github.head_ref }} # avoid "shallow update not allowed" error 55 | path: overlay-branch 56 | - name: Set up an overlay branch 57 | working-directory: overlay-branch 58 | run: | 59 | cp -av "$GITHUB_WORKSPACE/get-service-versions/tests/fixtures/." . 60 | git add . 61 | git commit -m "Add overlay branch for e2e-test of ${GITHUB_REF}" 62 | git push origin "HEAD:refs/heads/ns/monorepo-deploy-actions/overlay-${{ github.run_id }}/pr-${{ github.event.number }}" 63 | 64 | - uses: ./get-service-versions 65 | id: get-service-versions 66 | with: 67 | overlay: overlay-${{ github.run_id }} 68 | namespace: pr-${{ github.event.number }} 69 | destination-repository: ${{ github.repository }} 70 | 71 | - name: Check the service versions 72 | run: | 73 | set -x 74 | 75 | echo '${{ steps.get-service-versions.outputs.application-versions }}' > service_versions.json 76 | cat service_versions.json | jq "." 77 | 78 | # assertion 79 | [ "$(cat service_versions.json | jq -r '.[0].service')" = "a" ] 80 | [ "$(cat service_versions.json | jq -r '.[0].action')" = "git-push-service" ] 81 | [ "$(cat service_versions.json | jq -r '.[0].headRef')" = "main" ] 82 | [ "$(cat service_versions.json | jq -r '.[0].headSha')" = "main-branch-sha" ] 83 | - name: Clean up the overlay branch 84 | continue-on-error: true 85 | if: always() 86 | run: | 87 | git push origin --delete "refs/heads/ns/monorepo-deploy-actions/overlay-${{ github.run_id }}/pr-${{ github.event.number }}" 88 | -------------------------------------------------------------------------------- /.github/workflows/git-push-service.yaml: -------------------------------------------------------------------------------- 1 | name: git-push-service 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - git-push-service/** 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/git-push-service.yaml 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - git-push-service/** 15 | - '*.json' 16 | - '*.yaml' 17 | - .github/workflows/git-push-service.yaml 18 | 19 | defaults: 20 | run: 21 | working-directory: git-push-service 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | - run: npm install -g pnpm@latest-10 33 | - run: pnpm i 34 | - run: pnpm test 35 | 36 | e2e-test: 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 42 | with: 43 | node-version: 20 44 | - run: npm install -g pnpm@latest-10 45 | - run: pnpm i 46 | - run: pnpm build 47 | 48 | - uses: ./git-push-service 49 | with: 50 | manifests: | 51 | ${{ github.workspace }}/git-push-service/tests/fixtures/a/generated.yaml 52 | overlay: e2e-git-push-service 53 | namespace: ns-${{ github.run_number }} 54 | service: a 55 | destination-repository: ${{ github.repository }} 56 | 57 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 58 | with: 59 | ref: ns/monorepo-deploy-actions/e2e-git-push-service/ns-${{ github.run_number }} 60 | path: git-push-service/actual 61 | - run: find actual -type f 62 | - run: yq . actual/applications/ns-${{ github.run_number }}--a.yaml 63 | - run: yq . actual/services/a/generated.yaml 64 | 65 | - name: clean up the branch 66 | continue-on-error: true 67 | if: always() 68 | run: | 69 | git push origin --delete refs/heads/ns/monorepo-deploy-actions/e2e-git-push-service/ns-${{ github.run_number }} 70 | -------------------------------------------------------------------------------- /.github/workflows/git-push-services-patch.yaml: -------------------------------------------------------------------------------- 1 | name: git-push-services-patch 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - git-push-services-patch/** 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/git-push-services-patch.yaml 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - git-push-services-patch/** 15 | - '*.json' 16 | - '*.yaml' 17 | - .github/workflows/git-push-services-patch.yaml 18 | 19 | defaults: 20 | run: 21 | working-directory: git-push-services-patch 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | - run: npm install -g pnpm@latest-10 33 | - run: pnpm i 34 | - run: pnpm test 35 | 36 | e2e-test: 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 42 | with: 43 | node-version: 20 44 | - run: npm install -g pnpm@latest-10 45 | - run: pnpm i 46 | - run: pnpm build 47 | 48 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 49 | with: 50 | ref: ${{ github.head_ref }} # avoid "shallow update not allowed" error 51 | path: e2e-test-fixture 52 | - name: Set up a fixture branch 53 | working-directory: e2e-test-fixture 54 | run: | 55 | mkdir -vp services/a 56 | date > services/a/generated.yaml 57 | git add . 58 | git config user.email 'github-actions@github.com' 59 | git config user.name 'github-actions' 60 | git commit -m "e2e-test-fixture for ${GITHUB_REF}" 61 | git push origin "HEAD:refs/heads/ns/monorepo-deploy-actions/e2e-git-push-services-patch/ns-${{ github.run_number }}" 62 | 63 | # verify "add" operation 64 | - uses: ./git-push-services-patch 65 | with: 66 | patch: ${{ github.workspace }}/git-push-services-patch/tests/fixtures/kustomization.yaml 67 | operation: add 68 | overlay: e2e-git-push-services-patch 69 | namespace: ns-${{ github.run_number }} 70 | destination-repository: ${{ github.repository }} 71 | 72 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 73 | with: 74 | ref: ns/monorepo-deploy-actions/e2e-git-push-services-patch/ns-${{ github.run_number }} 75 | path: e2e-test-fixture 76 | - run: find services 77 | working-directory: e2e-test-fixture 78 | - run: test -f services/a/generated.yaml 79 | working-directory: e2e-test-fixture 80 | - run: test -f services/a/kustomization.yaml 81 | working-directory: e2e-test-fixture 82 | 83 | # verify "delete" operation 84 | - uses: ./git-push-services-patch 85 | with: 86 | patch: ${{ github.workspace }}/git-push-services-patch/tests/fixtures/kustomization.yaml 87 | operation: delete 88 | overlay: e2e-git-push-services-patch 89 | namespace: ns-${{ github.run_number }} 90 | destination-repository: ${{ github.repository }} 91 | 92 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 93 | with: 94 | ref: ns/monorepo-deploy-actions/e2e-git-push-services-patch/ns-${{ github.run_number }} 95 | path: e2e-test-fixture 96 | - run: find services 97 | working-directory: e2e-test-fixture 98 | - run: test -f services/a/generated.yaml 99 | working-directory: e2e-test-fixture 100 | - run: test ! -f services/a/kustomization.yaml 101 | working-directory: e2e-test-fixture 102 | 103 | - name: Clean up the fixture branch 104 | continue-on-error: true 105 | if: always() 106 | run: | 107 | git push origin --delete "refs/heads/ns/monorepo-deploy-actions/e2e-git-push-services-patch/ns-${{ github.run_number }}" 108 | -------------------------------------------------------------------------------- /.github/workflows/open-backport-pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: open-backport-pull-request 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - open-backport-pull-request/** 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/open-backport-pull-request.yaml 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - open-backport-pull-request/** 15 | - '*.json' 16 | - '*.yaml' 17 | - .github/workflows/open-backport-pull-request.yaml 18 | 19 | defaults: 20 | run: 21 | working-directory: open-backport-pull-request 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | - run: npm install -g pnpm@latest-10 33 | - run: pnpm i 34 | - run: pnpm test 35 | - run: pnpm build 36 | 37 | # Smoke test. This should not create any pull request. 38 | - if: github.event_name == 'pull_request' 39 | uses: ./open-backport-pull-request 40 | with: 41 | head-branch: main 42 | base-branch: main 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - .github/workflows/release.yaml 7 | push: 8 | branches: 9 | - main 10 | tags: 11 | - v* 12 | 13 | jobs: 14 | tag: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 18 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 19 | with: 20 | node-version: 20 21 | - run: npm install -g pnpm@latest-10 22 | - run: pnpm i 23 | - run: pnpm run --recursive build 24 | - uses: int128/release-typescript-action@4b93cf2f4b55fbce962db4c9acb89760c4a699d9 # v1.36.0 25 | -------------------------------------------------------------------------------- /.github/workflows/resolve-aws-secret-version.yaml: -------------------------------------------------------------------------------- 1 | name: resolve-aws-secret-version 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - resolve-aws-secret-version/** 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/resolve-aws-secret-version.yaml 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - resolve-aws-secret-version/** 15 | - '*.json' 16 | - '*.yaml' 17 | - .github/workflows/resolve-aws-secret-version.yaml 18 | 19 | defaults: 20 | run: 21 | working-directory: resolve-aws-secret-version 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | - run: npm install -g pnpm@latest-10 33 | - run: pnpm i 34 | - run: pnpm test 35 | 36 | e2e-test: 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 42 | with: 43 | node-version: 20 44 | - run: npm install -g pnpm@latest-10 45 | - run: pnpm i 46 | - run: pnpm build 47 | 48 | - uses: ./resolve-aws-secret-version 49 | id: resolve 50 | with: 51 | manifests: resolve-aws-secret-version/tests/fixtures/input-with-no-placeholder.yaml 52 | - run: git diff --exit-code 53 | -------------------------------------------------------------------------------- /.github/workflows/substitute.yaml: -------------------------------------------------------------------------------- 1 | name: substitute 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - substitute/** 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/substitute.yaml 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - substitute/** 15 | - '*.json' 16 | - '*.yaml' 17 | - .github/workflows/substitute.yaml 18 | 19 | defaults: 20 | run: 21 | working-directory: substitute 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | - run: npm install -g pnpm@latest-10 33 | - run: pnpm i 34 | - run: pnpm test 35 | 36 | e2e-test: 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 42 | with: 43 | node-version: 20 44 | - run: npm install -g pnpm@latest-10 45 | - run: pnpm i 46 | - run: pnpm build 47 | 48 | - uses: ./substitute 49 | with: 50 | files: | 51 | ${{ github.workspace }}/substitute/tests/fixtures/a/generated.yaml 52 | ${{ github.workspace }}/substitute/tests/fixtures/b/generated.yaml 53 | variables: | 54 | DOCKER_IMAGE=123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/deploy-actions/service:latest 55 | NAMESPACE=develop 56 | - run: git diff 57 | -------------------------------------------------------------------------------- /.github/workflows/template.yaml: -------------------------------------------------------------------------------- 1 | name: template 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - template/** 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/template.yaml 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - template/** 15 | - '*.json' 16 | - '*.yaml' 17 | - .github/workflows/template.yaml 18 | 19 | defaults: 20 | run: 21 | working-directory: template 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | - run: npm install -g pnpm@latest-10 33 | - run: pnpm i 34 | - run: pnpm test 35 | 36 | e2e-test: 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 42 | with: 43 | node-version: 20 44 | - run: npm install -g pnpm@latest-10 45 | - run: pnpm i 46 | - run: pnpm build 47 | - uses: ./template 48 | with: 49 | name: foo 50 | -------------------------------------------------------------------------------- /.github/workflows/update-outdated-pull-request-branch.yaml: -------------------------------------------------------------------------------- 1 | name: update-outdated-pull-request-branch 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - update-outdated-pull-request-branch/** 7 | - '*.json' 8 | - '*.yaml' 9 | - .github/workflows/update-outdated-pull-request-branch.yaml 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - update-outdated-pull-request-branch/** 15 | - '*.json' 16 | - '*.yaml' 17 | - .github/workflows/update-outdated-pull-request-branch.yaml 18 | 19 | defaults: 20 | run: 21 | working-directory: update-outdated-pull-request-branch 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 30 | with: 31 | node-version: 20 32 | - run: npm install -g pnpm@latest-10 33 | - run: pnpm i 34 | - run: pnpm test 35 | 36 | e2e-test: 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 42 | with: 43 | node-version: 20 44 | - run: npm install -g pnpm@latest-10 45 | - run: pnpm i 46 | - run: pnpm build 47 | # update-outdated-pull-request-branch action supports only pull_request event 48 | - if: github.event_name == 'pull_request' 49 | uses: ./update-outdated-pull-request-branch 50 | with: 51 | expiration-days: 7 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | lib/ 99 | dist/ 100 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 GitHub, Inc. and contributors 5 | Copyright (c) 2021 Hidetake Iwata 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # monorepo-deploy-actions 2 | 3 | This is a set of GitHub Actions to deploy microservices in a mono-repository (monorepo). 4 | 5 | ## Design 6 | 7 | ### Structure of monorepo 8 | 9 | Our monorepo contains a set of microservices with application code and Kubernetes manifests. 10 | Here is the example of directory structure. 11 | 12 | ``` 13 | monorepo 14 | ├── backend 15 | | ├── sources... 16 | | └── kubernetes 17 | | ├── base 18 | | └── overlays 19 | | ├── develop 20 | | | └── kustomization.yaml 21 | | └── staging 22 | | └── kustomization.yaml 23 | ├── frontend 24 | | ├── sources... 25 | | └── kubernetes 26 | | └── overlays 27 | | └── ... 28 | └── ... 29 | ``` 30 | 31 | We adopt this strcuture for the following advantages: 32 | 33 | - An owner of microservice (i.e. product team) has strong ownership for both application and manifest 34 | - We can change both application and manifest in a pull request 35 | 36 | We deploy a set of services from a branch to a namespace. 37 | For example, 38 | 39 | - When `develop` branch is pushed, 40 | - Build a Docker image from `develop` branch 41 | - Run kustomize build against `develop` overlay 42 | - Deploy to `develop` namespace 43 | - When a pull request is created, 44 | - Build a Docker image from head branch 45 | - Run kustomize build against `staging` overlay 46 | - Deploy to an ephemeral namespace like `pr-12345` 47 | 48 | Consequently, a structure of monorepo is like below. 49 | 50 | ``` 51 | monorepo 52 | └── ${service} 53 | └── kubernetes 54 | └── overlays 55 | └── ${overlay} 56 | └── kustomization.yaml 57 | ``` 58 | 59 | Here are the definitions of words. 60 | 61 | | Name | Description | Example | 62 | | ------------------------ | ------------------------------------------- | ---------- | 63 | | `overlay` | Name of the overlay to build with Kustomize | `staging` | 64 | | `namespace` | Namespace to deploy into a cluster | `pr-12345` | 65 | | `service` | Name of a microservice | `backend` | 66 | 67 | ### Structure of Argo CD Applications 68 | 69 | We adopt [App of Apps pattern of Argo CD](https://argoproj.github.io/argo-cd/operator-manual/cluster-bootstrapping/) for deployment hierarchy. 70 | 71 | For a typical namespace such as develop or production, it is deployed with the below applications. 72 | 73 | ```mermaid 74 | graph LR 75 | subgraph "generated-manifests" 76 | AppService[Application develop--SERVICE] --> Resources 77 | end 78 | App[Application monorepo--develop] --> AppService 79 | ``` 80 | 81 | For a pull request namespace, it is deployed with the below applications and [the pull request generator](https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Generators-Pull-Request/). 82 | 83 | ```mermaid 84 | graph LR 85 | subgraph "generated-manifests" 86 | AppService[Application pr-NUMBER--SERVICE] --> Resources 87 | end 88 | AppSet[ApplicationSet monorepo--pr] --> AppPr[Application pr-NUMBER] --> AppService 89 | ``` 90 | 91 | A namespace branch contains a set of generated manifest and Application manifest per a service. 92 | 93 | ``` 94 | destination-repository (branch: ns/${source-repository}/${overlay}/${namespace}) 95 | ├── applications 96 | | └── ${namespace}--${service}.yaml (Application) 97 | └── services 98 | └── ${service} 99 | └── generated.yaml 100 | ``` 101 | 102 | ## Development 103 | 104 | Node.js and pnpm is required. 105 | 106 | ```sh 107 | brew install node@20 108 | npm install -g pnpm@latest-10 109 | ``` 110 | 111 | ### Release workflow 112 | 113 | When a pull request is merged into main branch, a new minor release is created by GitHub Actions. 114 | See https://github.com/int128/release-typescript-action for details. 115 | 116 | ### Dependency update 117 | 118 | You can enable Renovate to update the dependencies. 119 | See https://github.com/int128/typescript-action-renovate-config for details. 120 | -------------------------------------------------------------------------------- /bootstrap-pull-request/README.md: -------------------------------------------------------------------------------- 1 | # bootstrap-pull-request [![bootstrap-pull-request](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/bootstrap-pull-request.yaml/badge.svg)](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/bootstrap-pull-request.yaml) 2 | 3 | This is an action to bootstrap the pull request namespace. 4 | When a pull request is created or updated, this action copies the service manifests from the prebuilt branch. 5 | 6 | ```mermaid 7 | graph LR 8 | subgraph Source repository 9 | Source[Source] 10 | end 11 | subgraph Destination repository 12 | subgraph Prebuilt branch 13 | PrebuiltApplicationManifest[Application manifest] 14 | Source --Build--> PrebuiltServiceManifest[Service manifest] 15 | end 16 | subgraph Namespace branch 17 | ApplicationManifest[Application manifest] 18 | ServiceManifest[Service manifest] 19 | NamespaceManifest[Namespace manifest] 20 | end 21 | end 22 | PrebuiltServiceManifest --Build--> ServiceManifest 23 | ``` 24 | 25 | ## Getting Started 26 | 27 | To bootstrap the pull request namespace, 28 | 29 | ```yaml 30 | name: pr-namespace / bootstrap 31 | 32 | on: 33 | pull_request: 34 | 35 | jobs: 36 | bootstrap-pull-request: 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 10 39 | steps: 40 | - uses: actions/checkout@v4 41 | # This action needs to be run after all services are pushed. 42 | - uses: int128/wait-for-workflows-action@v1 43 | with: 44 | filter-workflow-names: | 45 | * / deploy 46 | - uses: quipper/monorepo-deploy-actions/bootstrap-pull-request@v1 47 | with: 48 | overlay: pr 49 | namespace: pr-${{ github.event.number }} 50 | destination-repository: octocat/generated-manifests 51 | prebuilt-branch: prebuilt/source-repository/pr 52 | destination-repository-token: ${{ steps.destination-repository-github-app.outputs.token }} 53 | namespace-manifest: deploy-config/overlays/pr/namespace.yaml 54 | substitute-variables: | 55 | NAMESPACE=pr-${{ github.event.number }} 56 | ``` 57 | 58 | This action creates a namespace branch into the destination repository. 59 | 60 | ``` 61 | ns/${source-repository}/${overlay}/${namespace-prefix}${pull-request-number} 62 | ``` 63 | 64 | It creates the following directory structure. 65 | 66 | ``` 67 | . 68 | ├── applications 69 | | ├── namespace.yaml 70 | | └── ${namespace}--${service}.yaml 71 | └── services 72 | └── ${service} 73 | └── generated.yaml 74 | ``` 75 | 76 | It bootstraps the namespace branch by the following steps: 77 | 78 | 1. Delete the outdated application manifests 79 | 2. Copy the services from prebuilt branch 80 | 3. Write the namespace manifest 81 | 82 | ### 1. Delete the outdated application manifests 83 | 84 | This action deletes the outdated application manifests in the namespace branch. 85 | If an application manifest is pushed by `git-push-service` action on the current commit, this action preserves the application manifest. 86 | Otherwise, this action deletes the application manifest. 87 | 88 | For example, if the namespace branch has the below application manifests, 89 | this action deletes `applications/pr-123--backend.yaml`. 90 | 91 | ```yaml 92 | # applications/pr-123--backend.yaml 93 | apiVersion: argoproj.io/v1alpha1 94 | kind: Application 95 | metadata: 96 | annotations: 97 | github.action: bootstrap-pull-request 98 | ``` 99 | 100 | ```yaml 101 | # applications/pr-123--frontend.yaml 102 | apiVersion: argoproj.io/v1alpha1 103 | kind: Application 104 | metadata: 105 | annotations: 106 | github.action: git-push-service 107 | github.head-sha: 0123456789abcdef0123456789abcdef01234567 # The current commit 108 | ``` 109 | 110 | Note that this action needs to be run after all of `git-push-service` actions. 111 | 112 | ### 2. Copy the services from prebuilt branch 113 | 114 | This action copies the services from prebuilt branch to the namespace branch. 115 | 116 | For example, if the prebuilt branch has 2 services `backend` and `frontend`, 117 | the namespace branch will be the below structure. 118 | 119 | ``` 120 | . 121 | ├── applications 122 | | ├── pr-123--backend.yaml 123 | | └── pr-123--frontend.yaml 124 | └── services 125 | ├── backend 126 | | └── generated.yaml 127 | └── frontend 128 | └── generated.yaml 129 | ``` 130 | 131 | If the namespace branch contains any application manifests, this action will not overwrite them. 132 | 133 | All placeholders will be replaced during copying the service manifests. 134 | For example, if `NAMESPACE=pr-123` is given by `substitute-variables` input, 135 | this action will replace `${NAMESPACE}` with `pr-123`. 136 | 137 | ### 3. Write the namespace manifest 138 | 139 | This action copies the namespace manifest to path `/applications/namespace.yaml` in the namespace branch. 140 | 141 | ``` 142 | . 143 | └── applications 144 | └── namespace.yaml 145 | ``` 146 | 147 | All placeholders will be replaced during copying the namespace manifest. 148 | For example, if `NAMESPACE=pr-123` is given by `substitute-variables` input, 149 | this action will replace `${NAMESPACE}` with `pr-123`. 150 | 151 | ## Specification 152 | 153 | See [action.yaml](action.yaml). 154 | -------------------------------------------------------------------------------- /bootstrap-pull-request/action.yaml: -------------------------------------------------------------------------------- 1 | name: bootstrap-pull-request 2 | description: bootstrap the pull request namespace 3 | 4 | inputs: 5 | overlay: 6 | description: Name of overlay 7 | required: true 8 | namespace: 9 | description: Name of namespace 10 | required: true 11 | source-repository: 12 | description: Source repository 13 | required: true 14 | default: ${{ github.repository }} 15 | destination-repository: 16 | description: Destination repository 17 | required: true 18 | prebuilt-branch: 19 | description: Name of prebuilt branch in the destination repository. This input will be required in the future release. 20 | required: false 21 | destination-repository-token: 22 | description: GitHub token for destination repository 23 | required: true 24 | default: ${{ github.token }} 25 | namespace-manifest: 26 | description: Path to namespace manifest (optional) 27 | required: false 28 | substitute-variables: 29 | description: Pairs of key=value to substitute the prebuilt manifests (multiline) 30 | required: false 31 | current-head-sha: 32 | description: SHA of current head commit (For internal use) 33 | default: ${{ github.event.pull_request.head.sha || github.sha }} 34 | exclude-services: 35 | description: List of services to exclude from the overlay (multiline) 36 | required: false 37 | invert-exclude-services: 38 | description: Invert the exclude list 39 | required: false 40 | default: "false" 41 | 42 | outputs: 43 | services: 44 | description: JSON string of services. See prebuilt.ts for the JSON schema 45 | 46 | runs: 47 | using: 'node20' 48 | main: 'dist/index.js' 49 | -------------------------------------------------------------------------------- /bootstrap-pull-request/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | clearMocks: true, 5 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /bootstrap-pull-request/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bootstrap-pull-request", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "ncc build --source-map --license licenses.txt src/main.ts", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "1.11.1", 12 | "@actions/exec": "1.1.1", 13 | "@actions/github": "6.0.1", 14 | "@actions/glob": "0.5.0", 15 | "@actions/io": "1.1.3", 16 | "js-yaml": "4.1.0" 17 | }, 18 | "devDependencies": { 19 | "@types/js-yaml": "4.0.9" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /bootstrap-pull-request/src/git.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as exec from '@actions/exec' 3 | import * as os from 'os' 4 | import * as path from 'path' 5 | import { promises as fs } from 'fs' 6 | 7 | type CheckoutOptions = { 8 | repository: string 9 | branch: string 10 | token: string 11 | } 12 | 13 | export const checkout = async (opts: CheckoutOptions) => { 14 | const cwd = await fs.mkdtemp(path.join(process.env.RUNNER_TEMP || os.tmpdir(), 'git-')) 15 | core.info(`Cloning ${opts.repository} into ${cwd}`) 16 | await exec.exec('git', ['version'], { cwd }) 17 | await exec.exec('git', ['init', '--initial-branch', opts.branch], { cwd }) 18 | await exec.exec('git', ['config', '--local', 'gc.auto', '0'], { cwd }) 19 | await exec.exec('git', ['remote', 'add', 'origin', `https://github.com/${opts.repository}`], { cwd }) 20 | const credentials = Buffer.from(`x-access-token:${opts.token}`).toString('base64') 21 | core.setSecret(credentials) 22 | await exec.exec( 23 | 'git', 24 | ['config', '--local', 'http.https://github.com/.extraheader', `AUTHORIZATION: basic ${credentials}`], 25 | { cwd }, 26 | ) 27 | await exec.exec( 28 | 'git', 29 | ['fetch', '--no-tags', '--depth=1', 'origin', `+refs/heads/${opts.branch}:refs/remotes/origin/${opts.branch}`], 30 | { cwd }, 31 | ) 32 | await exec.exec('git', ['checkout', opts.branch], { cwd }) 33 | return cwd 34 | } 35 | 36 | export const checkoutOrInitRepository = async (opts: CheckoutOptions) => { 37 | const cwd = await fs.mkdtemp(path.join(process.env.RUNNER_TEMP || os.tmpdir(), 'git-')) 38 | core.info(`Cloning ${opts.repository} into ${cwd}`) 39 | await exec.exec('git', ['version'], { cwd }) 40 | await exec.exec('git', ['init', '--initial-branch', opts.branch], { cwd }) 41 | await exec.exec('git', ['config', '--local', 'gc.auto', '0'], { cwd }) 42 | await exec.exec('git', ['remote', 'add', 'origin', `https://github.com/${opts.repository}`], { cwd }) 43 | const credentials = Buffer.from(`x-access-token:${opts.token}`).toString('base64') 44 | core.setSecret(credentials) 45 | await exec.exec( 46 | 'git', 47 | ['config', '--local', 'http.https://github.com/.extraheader', `AUTHORIZATION: basic ${credentials}`], 48 | { cwd }, 49 | ) 50 | const code = await exec.exec( 51 | 'git', 52 | ['fetch', '--no-tags', '--depth=1', 'origin', `+refs/heads/${opts.branch}:refs/remotes/origin/${opts.branch}`], 53 | { cwd, ignoreReturnCode: true }, 54 | ) 55 | if (code === 0) { 56 | await exec.exec('git', ['checkout', opts.branch], { cwd }) 57 | return cwd 58 | } 59 | // If the remote branch does not exist, set up the tracking branch. 60 | await exec.exec('git', ['config', '--local', `branch.${opts.branch}.remote`, 'origin'], { cwd }) 61 | await exec.exec('git', ['config', '--local', `branch.${opts.branch}.merge`, `refs/heads/${opts.branch}`], { cwd }) 62 | return cwd 63 | } 64 | 65 | export const status = async (cwd: string): Promise => { 66 | const output = await exec.getExecOutput('git', ['status', '--porcelain'], { cwd }) 67 | return output.stdout.trim() 68 | } 69 | 70 | export const commit = async (cwd: string, message: string): Promise => { 71 | await exec.exec('git', ['add', '.'], { cwd }) 72 | await exec.exec('git', ['config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'], { cwd }) 73 | await exec.exec('git', ['config', 'user.name', 'github-actions[bot]'], { cwd }) 74 | await exec.exec('git', ['commit', '-m', message], { cwd }) 75 | await exec.exec('git', ['rev-parse', 'HEAD'], { cwd }) 76 | } 77 | 78 | export const pushByFastForward = async (cwd: string): Promise => { 79 | return await exec.exec('git', ['push', 'origin'], { cwd, ignoreReturnCode: true }) 80 | } 81 | -------------------------------------------------------------------------------- /bootstrap-pull-request/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { run } from './run.js' 3 | 4 | const main = async (): Promise => { 5 | const outputs = await run({ 6 | overlay: core.getInput('overlay', { required: true }), 7 | namespace: core.getInput('namespace', { required: true }), 8 | sourceRepository: core.getInput('source-repository', { required: true }), 9 | destinationRepository: core.getInput('destination-repository', { required: true }), 10 | prebuiltBranch: core.getInput('prebuilt-branch', { required: false }) || undefined, 11 | destinationRepositoryToken: core.getInput('destination-repository-token', { required: true }), 12 | namespaceManifest: core.getInput('namespace-manifest') || undefined, 13 | substituteVariables: core.getMultilineInput('substitute-variables'), 14 | currentHeadSha: core.getInput('current-head-sha', { required: true }), 15 | excludeServices: core.getMultilineInput('exclude-services'), 16 | invertExcludeServices: core.getBooleanInput('invert-exclude-services') || false, 17 | }) 18 | core.setOutput('services', JSON.stringify(outputs.services)) 19 | } 20 | 21 | main().catch((e: Error) => { 22 | core.setFailed(e) 23 | console.error(e) 24 | }) 25 | -------------------------------------------------------------------------------- /bootstrap-pull-request/src/namespace.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as io from '@actions/io' 3 | import { promises as fs } from 'fs' 4 | 5 | type NamespaceBranchInputs = { 6 | sourceRepository: string 7 | overlay: string 8 | namespace: string 9 | } 10 | 11 | export const getNamespaceBranch = (inputs: NamespaceBranchInputs) => { 12 | const [, sourceRepositoryName] = inputs.sourceRepository.split('/') 13 | return `ns/${sourceRepositoryName}/${inputs.overlay}/${inputs.namespace}` 14 | } 15 | 16 | type Inputs = { 17 | namespaceManifest: string 18 | namespaceDirectory: string 19 | substituteVariables: Map 20 | } 21 | 22 | export const writeNamespaceManifest = async (inputs: Inputs): Promise => { 23 | core.info(`Reading ${inputs.namespaceManifest}`) 24 | let content = (await fs.readFile(inputs.namespaceManifest)).toString() 25 | for (const [k, v] of inputs.substituteVariables) { 26 | const placeholder = '${' + k + '}' 27 | content = content.replaceAll(placeholder, v) 28 | } 29 | 30 | const namespacePath = `${inputs.namespaceDirectory}/applications/namespace.yaml` 31 | core.info(`Writing to ${namespacePath}`) 32 | await io.mkdirP(`${inputs.namespaceDirectory}/applications`) 33 | await fs.writeFile(namespacePath, content) 34 | } 35 | -------------------------------------------------------------------------------- /bootstrap-pull-request/src/retry.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | type RetrySpec = { 4 | maxAttempts: number 5 | waitMs: number 6 | } 7 | 8 | export const retryExponential = async (f: () => Promise, spec: RetrySpec): Promise => { 9 | for (let attempt = 1; ; attempt++) { 10 | const value = await f() 11 | if (!(value instanceof Error)) { 12 | return value 13 | } 14 | 15 | const retryOver = attempt >= spec.maxAttempts 16 | if (retryOver) { 17 | throw value 18 | } 19 | 20 | const waitMs = Math.ceil(Math.pow(attempt, 2) + Math.random() * spec.waitMs) 21 | core.warning(`Retrying after ${waitMs} ms: ${String(value)}`) 22 | await sleep(waitMs) 23 | } 24 | } 25 | 26 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 27 | -------------------------------------------------------------------------------- /bootstrap-pull-request/src/run.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import * as git from './git.js' 4 | import * as prebuilt from './prebuilt.js' 5 | import { retryExponential } from './retry.js' 6 | import { getNamespaceBranch, writeNamespaceManifest } from './namespace.js' 7 | 8 | type Inputs = { 9 | overlay: string 10 | namespace: string 11 | sourceRepository: string 12 | destinationRepository: string 13 | prebuiltBranch: string | undefined 14 | destinationRepositoryToken: string 15 | namespaceManifest: string | undefined 16 | substituteVariables: string[] 17 | currentHeadSha: string 18 | excludeServices: string[] 19 | invertExcludeServices: boolean 20 | } 21 | 22 | type Outputs = { 23 | services: prebuilt.Service[] 24 | } 25 | 26 | export const run = async (inputs: Inputs): Promise => { 27 | const outputs = await retryExponential(() => bootstrapNamespace(inputs), { 28 | maxAttempts: 50, 29 | waitMs: 10000, 30 | }) 31 | writeSummary(inputs, outputs.services) 32 | await core.summary.write() 33 | return outputs 34 | } 35 | 36 | const bootstrapNamespace = async (inputs: Inputs): Promise => { 37 | const [, sourceRepositoryName] = inputs.sourceRepository.split('/') 38 | // TODO: prebuiltBranch input will be required in the future release. 39 | if (inputs.prebuiltBranch === undefined) { 40 | core.warning('prebuilt-branch input will be required in the future release.') 41 | } 42 | const prebuiltBranch = inputs.prebuiltBranch ?? `prebuilt/${sourceRepositoryName}/${inputs.overlay}` 43 | 44 | const prebuiltDirectory = await checkoutPrebuiltBranch(inputs, prebuiltBranch) 45 | const namespaceDirectory = await checkoutNamespaceBranch(inputs) 46 | 47 | const substituteVariables = parseSubstituteVariables(inputs.substituteVariables) 48 | 49 | const services = await prebuilt.syncServicesFromPrebuilt({ 50 | currentHeadSha: inputs.currentHeadSha, 51 | overlay: inputs.overlay, 52 | namespace: inputs.namespace, 53 | sourceRepositoryName, 54 | destinationRepository: inputs.destinationRepository, 55 | prebuiltBranch, 56 | prebuiltDirectory, 57 | namespaceDirectory, 58 | substituteVariables, 59 | excludeServices: inputs.excludeServices, 60 | invertExcludeServices: inputs.invertExcludeServices, 61 | }) 62 | 63 | if (inputs.namespaceManifest) { 64 | await writeNamespaceManifest({ 65 | namespaceManifest: inputs.namespaceManifest, 66 | namespaceDirectory, 67 | substituteVariables, 68 | }) 69 | } 70 | 71 | if ((await git.status(namespaceDirectory)) === '') { 72 | core.info('Nothing to commit') 73 | return { services } 74 | } 75 | return await core.group(`Pushing the namespace branch`, async () => { 76 | await git.commit(namespaceDirectory, commitMessage(inputs.namespace)) 77 | const pushCode = await git.pushByFastForward(namespaceDirectory) 78 | if (pushCode > 0) { 79 | // Retry from checkout if fast-forward was failed 80 | return new Error(`git-push returned code ${pushCode}`) 81 | } 82 | return { services } 83 | }) 84 | } 85 | 86 | const checkoutPrebuiltBranch = async (inputs: Inputs, prebuiltBranch: string) => { 87 | return await core.group( 88 | `Checking out the prebuilt branch: ${prebuiltBranch}`, 89 | async () => 90 | await git.checkout({ 91 | repository: inputs.destinationRepository, 92 | branch: prebuiltBranch, 93 | token: inputs.destinationRepositoryToken, 94 | }), 95 | ) 96 | } 97 | 98 | const checkoutNamespaceBranch = async (inputs: Inputs) => { 99 | const namespaceBranch = getNamespaceBranch(inputs) 100 | return await core.group( 101 | `Checking out the namespace branch: ${namespaceBranch}`, 102 | async () => 103 | await git.checkoutOrInitRepository({ 104 | repository: inputs.destinationRepository, 105 | branch: namespaceBranch, 106 | token: inputs.destinationRepositoryToken, 107 | }), 108 | ) 109 | } 110 | 111 | const parseSubstituteVariables = (substituteVariables: string[]): Map => { 112 | const m = new Map() 113 | for (const s of substituteVariables) { 114 | const k = s.substring(0, s.indexOf('=')) 115 | const v = s.substring(s.indexOf('=') + 1) 116 | m.set(k, v) 117 | } 118 | return m 119 | } 120 | 121 | const writeSummary = (inputs: Inputs, services: prebuilt.Service[]) => { 122 | core.summary.addHeading('bootstrap-pull-request summary', 2) 123 | 124 | core.summary.addRaw('

') 125 | core.summary.addRaw('Pushed to the namespace branch: ') 126 | const namespaceBranch = getNamespaceBranch(inputs) 127 | const namespaceBranchUrl = `${github.context.serverUrl}/${inputs.destinationRepository}/tree/${namespaceBranch}` 128 | core.summary.addLink(namespaceBranchUrl, namespaceBranchUrl) 129 | core.summary.addRaw('

') 130 | 131 | core.summary.addTable([ 132 | [ 133 | { data: 'Service', header: true }, 134 | { data: 'Built from', header: true }, 135 | ], 136 | ...services.map((service) => { 137 | if (service.builtFrom.pullRequest) { 138 | const shaLink = `${service.builtFrom.pullRequest.headSha}` 139 | return [service.service, `Current pull request at ${shaLink}`] 140 | } 141 | if (service.builtFrom.prebuilt) { 142 | const shaLink = `${service.builtFrom.prebuilt.builtFrom.headSha}` 143 | return [service.service, `${service.builtFrom.prebuilt.builtFrom.headRef}@${shaLink}`] 144 | } 145 | return [service.service, '(unknown)'] 146 | }), 147 | ]) 148 | } 149 | 150 | const commitMessage = (namespace: string) => `Bootstrap namespace ${namespace} 151 | ${github.context.action} 152 | ${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/actions/runs/${github.context.runId}` 153 | -------------------------------------------------------------------------------- /bootstrap-pull-request/tests/fixtures/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: ${NAMESPACE} 5 | -------------------------------------------------------------------------------- /bootstrap-pull-request/tests/fixtures/prebuilt/applications/prebuilt--a.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: prebuilt--a 5 | namespace: argocd 6 | finalizers: 7 | - resources-finalizer.argocd.argoproj.io 8 | annotations: 9 | github.head-ref: main 10 | github.head-sha: main-branch-sha 11 | github.action: git-push-service 12 | spec: 13 | project: source-repository 14 | source: 15 | repoURL: https://github.com/octocat/destination-repository.git 16 | targetRevision: prebuilt/source-repository/pr 17 | path: services/a 18 | destination: 19 | server: https://kubernetes.default.svc 20 | namespace: prebuilt 21 | syncPolicy: 22 | automated: 23 | prune: true 24 | -------------------------------------------------------------------------------- /bootstrap-pull-request/tests/fixtures/prebuilt/applications/prebuilt--b.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: prebuilt--b 5 | namespace: argocd 6 | finalizers: 7 | - resources-finalizer.argocd.argoproj.io 8 | annotations: 9 | github.head-ref: main 10 | github.head-sha: main-branch-sha 11 | github.action: git-push-service 12 | spec: 13 | project: source-repository 14 | source: 15 | repoURL: https://github.com/octocat/destination-repository.git 16 | targetRevision: prebuilt/source-repository/pr 17 | path: services/b 18 | destination: 19 | server: https://kubernetes.default.svc 20 | namespace: prebuilt 21 | syncPolicy: 22 | automated: 23 | prune: true 24 | -------------------------------------------------------------------------------- /bootstrap-pull-request/tests/fixtures/prebuilt/services/a/generated.yaml: -------------------------------------------------------------------------------- 1 | # fixture 2 | name: a 3 | namespace: ${NAMESPACE} 4 | -------------------------------------------------------------------------------- /bootstrap-pull-request/tests/fixtures/prebuilt/services/b/generated.yaml: -------------------------------------------------------------------------------- 1 | # fixture 2 | name: b 3 | namespace: ${NAMESPACE} 4 | -------------------------------------------------------------------------------- /bootstrap-pull-request/tests/namespace.test.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os' 2 | import * as path from 'path' 3 | import { promises as fs } from 'fs' 4 | import { writeNamespaceManifest } from '../src/namespace.js' 5 | 6 | const readContent = async (filename: string) => (await fs.readFile(filename)).toString() 7 | 8 | describe('writeNamespaceManifest', () => { 9 | it('should copy the manifests', async () => { 10 | const namespaceDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'bootstrap-pull-request-')) 11 | 12 | await writeNamespaceManifest({ 13 | namespaceManifest: `${__dirname}/fixtures/namespace.yaml`, 14 | namespaceDirectory, 15 | substituteVariables: new Map([['NAMESPACE', 'pr-123']]), 16 | }) 17 | 18 | expect(await readContent(`${namespaceDirectory}/applications/namespace.yaml`)).toBe(`\ 19 | apiVersion: v1 20 | kind: Namespace 21 | metadata: 22 | name: pr-123 23 | `) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /bootstrap-pull-request/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /create-deploy-pull-request/README.md: -------------------------------------------------------------------------------- 1 | # create-deploy-pull-request [![create-deploy-pull-request](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/create-deploy-pull-request.yaml/badge.svg)](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/create-deploy-pull-request.yaml) 2 | 3 | This action creates a pull request to deploy a service. 4 | It assumes a branch strategy such as Git Flow or GitLab Flow. 5 | 6 | ## Getting Started 7 | 8 | To create a pull request from `release` to `production`, 9 | 10 | ```yaml 11 | jobs: 12 | create: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: quipper/monorepo-deploy-actions/create-deploy-pull-request@v1 16 | with: 17 | head-branch: release 18 | base-branch: production 19 | title: Deploy from release to production 20 | body: | 21 | Hi @${{ github.actor }} 22 | This will deploy the services to production environment. 23 | ``` 24 | 25 | If the base branch does not exist, this action creates it from the head branch. 26 | If a pull request already exists between head and base, this action does nothing. 27 | 28 | This action appends the current timestamp to the title of pull request. 29 | It is the local time in form of ISO 8601, such as `2023-09-07 15:01:02`. 30 | You may need to set your `time-zone`. 31 | 32 | ### Pin head commit 33 | 34 | It is not recommended to create a pull request from main branch directly, 35 | because the head commit will be changed when main branch is updated. 36 | 37 | To pin the head commit of a pull request, 38 | 39 | ```yaml 40 | jobs: 41 | create: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | - run: git push origin "refs/heads/main:refs/heads/main-${{ github.run_id }}" 46 | 47 | - uses: quipper/monorepo-deploy-actions/create-deploy-pull-request@v1 48 | with: 49 | head-branch: main-${{ github.run_id }} 50 | base-branch: release 51 | title: Deploy from main to release 52 | body: | 53 | Hi @${{ github.actor }} 54 | This will deploy the services to release environment. 55 | ``` 56 | 57 | ## Specification 58 | 59 | See [action.yaml](action.yaml). 60 | -------------------------------------------------------------------------------- /create-deploy-pull-request/action.yaml: -------------------------------------------------------------------------------- 1 | name: create-deploy-pull-request 2 | description: create a pull request to deploy a service 3 | 4 | inputs: 5 | head-branch: 6 | description: Name of head branch 7 | required: true 8 | base-branch: 9 | description: Name of base branch 10 | required: true 11 | title: 12 | description: Title of pull request 13 | required: true 14 | body: 15 | description: Body of pull request 16 | required: true 17 | labels: 18 | description: Label of pull request (multiline) 19 | required: false 20 | draft: 21 | description: Set the pull request to draft 22 | required: true 23 | default: 'true' 24 | time-zone: 25 | description: Time-zone for timestamp in title 26 | required: false 27 | token: 28 | description: GitHub token 29 | required: true 30 | default: ${{ github.token }} 31 | 32 | outputs: 33 | pull-request-url: 34 | description: URL of the pull request if created 35 | pull-request-number: 36 | description: Number of the pull request if created 37 | 38 | runs: 39 | using: 'node20' 40 | main: 'dist/index.js' 41 | -------------------------------------------------------------------------------- /create-deploy-pull-request/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | clearMocks: true, 5 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /create-deploy-pull-request/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-deploy-pull-request", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "ncc build --source-map --license licenses.txt src/main.ts", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "1.11.1", 12 | "@actions/github": "6.0.1" 13 | }, 14 | "devDependencies": { 15 | "msw": "2.10.2" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /create-deploy-pull-request/src/branch.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { GitHub } from '@actions/github/lib/utils' 3 | 4 | type Octokit = InstanceType 5 | 6 | type CheckIfBranchExistsOptions = { 7 | owner: string 8 | repo: string 9 | branch: string 10 | } 11 | 12 | export const checkIfBranchExists = async (octokit: Octokit, options: CheckIfBranchExistsOptions): Promise => { 13 | try { 14 | await octokit.rest.repos.getBranch(options) 15 | return true 16 | } catch (error) { 17 | if (isRequestError(error) && error.status === 404) { 18 | return false 19 | } 20 | throw error 21 | } 22 | } 23 | 24 | type CreateUpdateBranchOptions = { 25 | owner: string 26 | repo: string 27 | fromBranch: string 28 | toBranch: string 29 | } 30 | 31 | export const createBranch = async (octokit: Octokit, options: CreateUpdateBranchOptions) => { 32 | core.info(`Getting the commit of branch ${options.fromBranch}`) 33 | const { data: fromBranch } = await octokit.rest.repos.getBranch({ 34 | owner: options.owner, 35 | repo: options.repo, 36 | branch: options.fromBranch, 37 | }) 38 | 39 | core.info(`From commit ${fromBranch.commit.sha} of branch ${options.fromBranch}`) 40 | core.info(`Creating a new branch ${options.toBranch}`) 41 | await octokit.rest.git.createRef({ 42 | owner: options.owner, 43 | repo: options.repo, 44 | ref: `refs/heads/${options.toBranch}`, 45 | sha: fromBranch.commit.sha, 46 | }) 47 | } 48 | 49 | type RequestError = Error & { status: number } 50 | 51 | const isRequestError = (error: unknown): error is RequestError => 52 | error instanceof Error && 'status' in error && typeof error.status === 'number' 53 | -------------------------------------------------------------------------------- /create-deploy-pull-request/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import { run } from './run.js' 4 | 5 | const main = async (): Promise => { 6 | const outputs = await run( 7 | { 8 | head: core.getInput('head-branch', { required: true }), 9 | base: core.getInput('base-branch', { required: true }), 10 | title: core.getInput('title', { required: true }), 11 | body: core.getInput('body', { required: true }), 12 | labels: core.getMultilineInput('labels'), 13 | draft: core.getBooleanInput('draft', { required: true }), 14 | owner: github.context.repo.owner, 15 | repo: github.context.repo.repo, 16 | actor: github.context.actor, 17 | eventName: github.context.eventName, 18 | now: () => new Date(), 19 | timeZone: core.getInput('time-zone') || undefined, 20 | }, 21 | github.getOctokit(core.getInput('token', { required: true })), 22 | ) 23 | await core.summary.write() 24 | if (outputs.pullRequestUrl) { 25 | core.setOutput('pull-request-url', outputs.pullRequestUrl) 26 | } 27 | if (outputs.pullRequestNumber) { 28 | core.setOutput('pull-request-number', outputs.pullRequestNumber) 29 | } 30 | } 31 | 32 | main().catch((e: Error) => { 33 | core.setFailed(e) 34 | console.error(e) 35 | }) 36 | -------------------------------------------------------------------------------- /create-deploy-pull-request/src/pull.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { GitHub } from '@actions/github/lib/utils' 3 | 4 | type Octokit = InstanceType 5 | 6 | type CreatePullOptions = { 7 | owner: string 8 | repo: string 9 | head: string 10 | base: string 11 | title: string 12 | body: string 13 | draft: boolean 14 | labels: string[] 15 | reviewers: string[] 16 | assignees: string[] 17 | } 18 | 19 | type Pull = { 20 | html_url: string 21 | number: number 22 | } 23 | 24 | export const createPull = async (octokit: Octokit, options: CreatePullOptions): Promise => { 25 | core.info(`Finding an existing pull request of ${options.head} -> ${options.base}`) 26 | const { data: exists } = await octokit.rest.pulls.list({ 27 | owner: options.owner, 28 | repo: options.repo, 29 | base: options.base, 30 | // head must be in the format of `organization:ref-name` 31 | // https://docs.github.com/en/rest/pulls/pulls#list-pull-requests 32 | head: `${options.owner}:${options.head}`, 33 | }) 34 | if (exists.length > 0) { 35 | core.info(`Already exists: ${exists.map((pull) => pull.html_url).join()}`) 36 | const pull = exists[0] 37 | core.summary.addRaw(`Already exists [#${pull.number} ${pull.title}](${pull.html_url})`, true) 38 | return pull 39 | } 40 | 41 | core.info(`Creating a pull request from ${options.head} to ${options.base}`) 42 | const { data: pull } = await octokit.rest.pulls.create({ 43 | owner: options.owner, 44 | repo: options.repo, 45 | base: options.base, 46 | head: options.head, 47 | title: options.title, 48 | body: options.body, 49 | draft: options.draft, 50 | }) 51 | core.info(`Created ${pull.html_url}`) 52 | core.summary.addRaw(`Created [#${pull.number} ${pull.title}](${pull.html_url})`, true) 53 | 54 | if (options.reviewers.length > 0) { 55 | core.info(`Requesting a review to ${options.reviewers.join(', ')}`) 56 | await octokit.rest.pulls.requestReviewers({ 57 | owner: options.owner, 58 | repo: options.repo, 59 | pull_number: pull.number, 60 | reviewers: options.reviewers, 61 | }) 62 | } 63 | if (options.assignees.length > 0) { 64 | core.info(`Adding assignees ${options.assignees.join(', ')}`) 65 | await octokit.rest.issues.addAssignees({ 66 | owner: options.owner, 67 | repo: options.repo, 68 | issue_number: pull.number, 69 | assignees: options.assignees, 70 | }) 71 | } 72 | if (options.labels.length > 0) { 73 | core.info(`Adding labels ${options.labels.join(', ')}`) 74 | await octokit.rest.issues.addLabels({ 75 | owner: options.owner, 76 | repo: options.repo, 77 | issue_number: pull.number, 78 | labels: options.labels, 79 | }) 80 | } 81 | return pull 82 | } 83 | -------------------------------------------------------------------------------- /create-deploy-pull-request/src/run.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import { createPull } from './pull.js' 4 | import { checkIfBranchExists, createBranch } from './branch.js' 5 | 6 | type Octokit = ReturnType 7 | 8 | type Inputs = { 9 | head: string 10 | base: string 11 | title: string 12 | body: string 13 | draft: boolean 14 | labels: string[] 15 | owner: string 16 | repo: string 17 | actor: string 18 | eventName: string 19 | now: () => Date 20 | timeZone: string | undefined 21 | } 22 | 23 | type Outputs = { 24 | pullRequestUrl?: string 25 | pullRequestNumber?: number 26 | } 27 | 28 | export const run = async (inputs: Inputs, octokit: Octokit): Promise => { 29 | core.info(`Checking if ${inputs.base} branch exists`) 30 | const baseBranchExists = await checkIfBranchExists(octokit, { 31 | owner: inputs.owner, 32 | repo: inputs.repo, 33 | branch: inputs.base, 34 | }) 35 | if (!baseBranchExists) { 36 | core.info(`Creating ${inputs.base} branch because it does not exist`) 37 | await createBranch(octokit, { 38 | owner: inputs.owner, 39 | repo: inputs.repo, 40 | fromBranch: inputs.head, 41 | toBranch: inputs.base, 42 | }) 43 | core.summary.addRaw(`Created ${inputs.base} branch`, true) 44 | return {} 45 | } 46 | 47 | const reviewers = [] 48 | if (inputs.eventName === 'workflow_dispatch') { 49 | core.info(`Requesting a review to @${inputs.actor} because the workflow was manually triggered`) 50 | reviewers.push(inputs.actor) 51 | } 52 | 53 | core.info(`Creating a pull request from ${inputs.head} to ${inputs.base}`) 54 | const timestamp = formatISO8601LocalTime(inputs.now(), inputs.timeZone) 55 | const pull = await createPull(octokit, { 56 | owner: inputs.owner, 57 | repo: inputs.repo, 58 | head: inputs.head, 59 | base: inputs.base, 60 | title: `${inputs.title} at ${timestamp}`, 61 | body: inputs.body, 62 | draft: inputs.draft, 63 | labels: inputs.labels, 64 | reviewers, 65 | assignees: reviewers, 66 | }) 67 | return { 68 | pullRequestUrl: pull.html_url, 69 | pullRequestNumber: pull.number, 70 | } 71 | } 72 | 73 | // https://stackoverflow.com/questions/25050034/get-iso-8601-using-intl-datetimeformat 74 | const formatISO8601LocalTime = (d: Date, timeZone?: string) => d.toLocaleString('sv-SE', { timeZone }) 75 | -------------------------------------------------------------------------------- /create-deploy-pull-request/tests/github.ts: -------------------------------------------------------------------------------- 1 | import * as github from '@actions/github' 2 | import { setupServer } from 'msw/node' 3 | 4 | export const server = setupServer() 5 | 6 | export const getOctokit = () => github.getOctokit('GITHUB_TOKEN', { request: { fetch } }) 7 | -------------------------------------------------------------------------------- /create-deploy-pull-request/tests/run.test.ts: -------------------------------------------------------------------------------- 1 | import { run } from '../src/run.js' 2 | import { getOctokit, server } from './github.js' 3 | import { http, HttpResponse } from 'msw' 4 | 5 | beforeAll(() => server.listen()) 6 | afterEach(() => server.resetHandlers()) 7 | afterAll(() => server.close()) 8 | 9 | it('should create base branch if not exist', async () => { 10 | server.use( 11 | http.get('https://api.github.com/repos/test-owner/test-repo-1/branches/release', () => 12 | HttpResponse.json({ 13 | commit: { 14 | sha: 'commit-sha-1-release', 15 | }, 16 | }), 17 | ), 18 | http.get( 19 | 'https://api.github.com/repos/test-owner/test-repo-1/branches/production', 20 | () => new HttpResponse(null, { status: 404 }), 21 | ), 22 | http.post( 23 | 'https://api.github.com/repos/test-owner/test-repo-1/git/refs', 24 | () => new HttpResponse(null, { status: 201 }), 25 | ), 26 | ) 27 | const outputs = await run( 28 | { 29 | head: 'release', 30 | base: 'production', 31 | title: 'Deploy service to production', 32 | body: 'Hello', 33 | actor: 'octocat', 34 | eventName: 'workflow_dispatch', 35 | labels: [], 36 | draft: true, 37 | owner: 'test-owner', 38 | repo: 'test-repo-1', 39 | now: () => new Date(Date.UTC(2023, 8, 7, 6, 1, 2, 0)), 40 | timeZone: 'Asia/Tokyo', 41 | }, 42 | getOctokit(), 43 | ) 44 | expect(outputs.pullRequestUrl).toBeUndefined() 45 | }) 46 | 47 | it('should create pull request if base branch exists', async () => { 48 | server.use( 49 | http.get('https://api.github.com/repos/test-owner/test-repo-2/branches/release', () => 50 | HttpResponse.json({ 51 | commit: { 52 | sha: 'commit-sha-2-release', 53 | }, 54 | }), 55 | ), 56 | http.get('https://api.github.com/repos/test-owner/test-repo-2/branches/production', () => 57 | HttpResponse.json({ 58 | // Omit an example response 59 | }), 60 | ), 61 | http.get('https://api.github.com/repos/test-owner/test-repo-2/pulls', ({ request }) => { 62 | const url = new URL(request.url) 63 | expect(url.searchParams.get('base')).toBe('production') 64 | expect(url.searchParams.get('head')).toBe('test-owner:release') 65 | return HttpResponse.json([]) 66 | }), 67 | http.post('https://api.github.com/repos/test-owner/test-repo-2/pulls', () => 68 | HttpResponse.json({ 69 | html_url: 'https://github.com/test-owner/test-repo-2/pulls/100', 70 | number: 100, 71 | }), 72 | ), 73 | http.post('https://api.github.com/repos/test-owner/test-repo-2/pulls/100/requested_reviewers', () => 74 | HttpResponse.json({ 75 | // Omit an example response 76 | }), 77 | ), 78 | http.post('https://api.github.com/repos/test-owner/test-repo-2/issues/100/assignees', () => 79 | HttpResponse.json({ 80 | // Omit an example response 81 | }), 82 | ), 83 | ) 84 | const outputs = await run( 85 | { 86 | head: 'release', 87 | base: 'production', 88 | title: 'Deploy service to production', 89 | body: 'Hello', 90 | actor: 'octocat', 91 | eventName: 'workflow_dispatch', 92 | labels: [], 93 | draft: true, 94 | owner: 'test-owner', 95 | repo: 'test-repo-2', 96 | now: () => new Date(Date.UTC(2023, 8, 7, 6, 1, 2, 0)), 97 | timeZone: 'Asia/Tokyo', // UTC+9 98 | }, 99 | getOctokit(), 100 | ) 101 | expect(outputs).toStrictEqual({ 102 | pullRequestUrl: 'https://github.com/test-owner/test-repo-2/pulls/100', 103 | pullRequestNumber: 100, 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /create-deploy-pull-request/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /environment-matrix/action.yaml: -------------------------------------------------------------------------------- 1 | name: environment-matrix 2 | description: generate a JSON for matrix deploy 3 | inputs: 4 | rules: 5 | description: YAML string of rules 6 | required: true 7 | token: 8 | description: GitHub token, required if service is set 9 | required: false 10 | default: ${{ github.token }} 11 | outputs: 12 | json: 13 | description: JSON string of environments 14 | runs: 15 | using: 'node20' 16 | main: 'dist/index.js' 17 | -------------------------------------------------------------------------------- /environment-matrix/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | clearMocks: true, 5 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /environment-matrix/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "environment-matrix", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "ncc build --source-map --license licenses.txt src/main.ts", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "1.11.1", 12 | "@actions/github": "6.0.1", 13 | "@actions/glob": "0.5.0", 14 | "@octokit/plugin-retry": "6.0.1", 15 | "ajv": "8.17.1", 16 | "js-yaml": "4.1.0", 17 | "minimatch": "10.0.1" 18 | }, 19 | "devDependencies": { 20 | "@types/js-yaml": "4.0.9", 21 | "@types/minimatch": "5.1.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /environment-matrix/src/deployment.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import { Octokit, assertPullRequestPayload } from './github.js' 4 | import assert from 'assert' 5 | import { GitHubDeployment } from './rule.js' 6 | 7 | type Context = Pick 8 | 9 | export const createDeployment = async (octokit: Octokit, context: Context, deployment: GitHubDeployment) => { 10 | core.info(`Finding the old deployments for environment ${deployment.environment}`) 11 | const oldDeployments = await octokit.rest.repos.listDeployments({ 12 | owner: context.repo.owner, 13 | repo: context.repo.repo, 14 | environment: deployment.environment, 15 | }) 16 | 17 | core.info(`Deleting ${oldDeployments.data.length} deployment(s)`) 18 | for (const deployment of oldDeployments.data) { 19 | try { 20 | await octokit.rest.repos.deleteDeployment({ 21 | owner: context.repo.owner, 22 | repo: context.repo.repo, 23 | deployment_id: deployment.id, 24 | }) 25 | } catch (error) { 26 | if (isRequestError(error)) { 27 | core.warning(`Could not delete the old deployment ${deployment.url}: ${error.status} ${error.message}`) 28 | continue 29 | } 30 | throw error 31 | } 32 | core.info(`Deleted the old deployment ${deployment.url}`) 33 | } 34 | core.info(`Deleted ${oldDeployments.data.length} deployment(s)`) 35 | 36 | const ref = getDeploymentRef(context) 37 | core.info(`Creating a deployment for environment=${deployment.environment}, ref=${ref}`) 38 | const created = await octokit.rest.repos.createDeployment({ 39 | owner: context.repo.owner, 40 | repo: context.repo.repo, 41 | ref, 42 | environment: deployment.environment, 43 | auto_merge: false, 44 | required_contexts: [], 45 | transient_environment: context.eventName === 'pull_request', 46 | }) 47 | assert.strictEqual(created.status, 201) 48 | core.info(`Created a deployment ${created.data.url}`) 49 | 50 | // If the deployment is not deployed for a while, it will cause the following error: 51 | // This branch had an error being deployed 52 | // 1 abandoned deployment 53 | // 54 | // To avoid this, we set the deployment status to inactive immediately. 55 | core.info(`Setting the deployment status to inactive`) 56 | await octokit.rest.repos.createDeploymentStatus({ 57 | owner: context.repo.owner, 58 | repo: context.repo.repo, 59 | deployment_id: created.data.id, 60 | state: 'inactive', 61 | }) 62 | core.info(`Set the deployment status to inactive`) 63 | return created.data 64 | } 65 | 66 | const getDeploymentRef = (context: Context): string => { 67 | if (context.eventName === 'pull_request') { 68 | // Set the head ref to associate a deployment with the pull request 69 | assertPullRequestPayload(context.payload.pull_request) 70 | return context.payload.pull_request.head.ref 71 | } 72 | return context.ref 73 | } 74 | 75 | type RequestError = Error & { status: number } 76 | 77 | const isRequestError = (error: unknown): error is RequestError => 78 | error instanceof Error && 'status' in error && typeof error.status === 'number' 79 | -------------------------------------------------------------------------------- /environment-matrix/src/github.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import * as pluginRetry from '@octokit/plugin-retry' 3 | import { GitHub, getOctokitOptions } from '@actions/github/lib/utils' 4 | 5 | export type Octokit = InstanceType 6 | 7 | export const getOctokit = (token: string): Octokit => { 8 | const MyOctokit = GitHub.plugin(pluginRetry.retry) 9 | return new MyOctokit(getOctokitOptions(token, { previews: ['ant-man', 'flash'] })) 10 | } 11 | 12 | // picked from https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request 13 | export type PullRequestPayload = { 14 | head: { 15 | ref: string 16 | } 17 | base: { 18 | ref: string 19 | } 20 | } 21 | 22 | export function assertPullRequestPayload(x: unknown): asserts x is PullRequestPayload { 23 | assert(typeof x === 'object') 24 | assert(x != null) 25 | 26 | assert('base' in x) 27 | assert(typeof x.base === 'object') 28 | assert(x.base != null) 29 | assert('ref' in x.base) 30 | assert(typeof x.base.ref === 'string') 31 | 32 | assert('head' in x) 33 | assert(typeof x.head === 'object') 34 | assert(x.head != null) 35 | assert('ref' in x.head) 36 | assert(typeof x.head.ref === 'string') 37 | } 38 | -------------------------------------------------------------------------------- /environment-matrix/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { run } from './run.js' 3 | 4 | const main = async (): Promise => { 5 | const outputs = await run({ 6 | rules: core.getInput('rules', { required: true }), 7 | token: core.getInput('token'), 8 | }) 9 | core.setOutput('json', outputs.json) 10 | } 11 | 12 | main().catch((e: Error) => { 13 | core.setFailed(e) 14 | console.error(e) 15 | }) 16 | -------------------------------------------------------------------------------- /environment-matrix/src/matcher.ts: -------------------------------------------------------------------------------- 1 | import * as github from '@actions/github' 2 | import * as glob from '@actions/glob' 3 | import { minimatch } from 'minimatch' 4 | import { Environment, Rule, Rules } from './rule.js' 5 | import { assertPullRequestPayload } from './github.js' 6 | 7 | type Context = Pick 8 | 9 | export const findEnvironmentsFromRules = async (rules: Rules, context: Context): Promise => { 10 | for (const rule of rules) { 11 | if (matchRule(rule, context)) { 12 | const environments = [] 13 | for (const environment of rule.environments) { 14 | if (await matchEnvironment(environment)) { 15 | environments.push(environment) 16 | } 17 | } 18 | return environments 19 | } 20 | } 21 | } 22 | 23 | const matchRule = (rule: Rule, context: Context): boolean => { 24 | if (context.eventName === 'pull_request' && rule.pull_request !== undefined) { 25 | assertPullRequestPayload(context.payload.pull_request) 26 | return ( 27 | minimatch(context.payload.pull_request.base.ref, rule.pull_request.base) && 28 | minimatch(context.payload.pull_request.head.ref, rule.pull_request.head) 29 | ) 30 | } 31 | if (context.eventName === 'push' && rule.push !== undefined) { 32 | return minimatch(context.ref, rule.push.ref) 33 | } 34 | return false 35 | } 36 | 37 | export const matchEnvironment = async (environment: Environment): Promise => { 38 | if (environment['if-file-exists']) { 39 | const globber = await glob.create(environment['if-file-exists'], { matchDirectories: false }) 40 | const matches = await globber.glob() 41 | return matches.length > 0 42 | } 43 | return true 44 | } 45 | -------------------------------------------------------------------------------- /environment-matrix/src/rule.ts: -------------------------------------------------------------------------------- 1 | import * as yaml from 'js-yaml' 2 | import Ajv, { JTDSchemaType } from 'ajv/dist/jtd' 3 | 4 | export type Outputs = Record 5 | 6 | const OutputsSchema: JTDSchemaType = { 7 | values: { 8 | type: 'string', 9 | }, 10 | } 11 | 12 | export type GitHubDeployment = { 13 | environment: string 14 | } 15 | 16 | const GitHubDeploymentSchema: JTDSchemaType = { 17 | properties: { 18 | environment: { 19 | type: 'string', 20 | }, 21 | }, 22 | } 23 | 24 | export type Environment = { 25 | outputs: Outputs 26 | 'if-file-exists'?: string 27 | 'github-deployment'?: GitHubDeployment 28 | } 29 | 30 | const EnvironmentSchema: JTDSchemaType = { 31 | properties: { 32 | outputs: OutputsSchema, 33 | }, 34 | optionalProperties: { 35 | 'if-file-exists': { 36 | type: 'string', 37 | }, 38 | 'github-deployment': GitHubDeploymentSchema, 39 | }, 40 | } 41 | 42 | export type Rule = { 43 | pull_request?: { 44 | base: string 45 | head: string 46 | } 47 | push?: { 48 | ref: string 49 | } 50 | environments: Environment[] 51 | } 52 | 53 | const RuleSchema: JTDSchemaType = { 54 | properties: { 55 | environments: { 56 | elements: EnvironmentSchema, 57 | }, 58 | }, 59 | optionalProperties: { 60 | pull_request: { 61 | properties: { 62 | base: { 63 | type: 'string', 64 | }, 65 | head: { 66 | type: 'string', 67 | }, 68 | }, 69 | }, 70 | push: { 71 | properties: { 72 | ref: { 73 | type: 'string', 74 | }, 75 | }, 76 | }, 77 | }, 78 | } 79 | 80 | export type Rules = Rule[] 81 | 82 | const rulesSchema: JTDSchemaType = { 83 | elements: RuleSchema, 84 | } 85 | 86 | const ajv = new Ajv() 87 | export const validateRules = ajv.compile(rulesSchema) 88 | 89 | export const parseRulesYAML = (s: string): Rules => { 90 | const rules = yaml.load(s) 91 | if (!validateRules(rules)) { 92 | if (validateRules.errors) { 93 | throw new Error( 94 | `invalid rules YAML: ${validateRules.errors.map((e) => `${e.instancePath} ${e.message || ''}`).join(', ')}`, 95 | ) 96 | } 97 | throw new Error('invalid rules YAML') 98 | } 99 | return rules 100 | } 101 | -------------------------------------------------------------------------------- /environment-matrix/src/run.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import { parseRulesYAML } from './rule.js' 4 | import { findEnvironmentsFromRules } from './matcher.js' 5 | import { createDeployment } from './deployment.js' 6 | import { getOctokit } from './github.js' 7 | 8 | type Inputs = { 9 | rules: string 10 | token: string 11 | } 12 | 13 | type Outputs = { 14 | json: Record[] 15 | } 16 | 17 | export const run = async (inputs: Inputs): Promise => { 18 | const rules = parseRulesYAML(inputs.rules) 19 | core.startGroup('Rules') 20 | core.info(JSON.stringify(rules, undefined, 2)) 21 | core.endGroup() 22 | 23 | const environments = await findEnvironmentsFromRules(rules, github.context) 24 | if (environments === undefined) { 25 | throw new Error(`no environment to deploy`) 26 | } 27 | 28 | core.info(`Creating GitHub Deployments for environments`) 29 | const octokit = getOctokit(inputs.token) 30 | for (const environment of environments) { 31 | if (environment['github-deployment']) { 32 | const deployment = await createDeployment(octokit, github.context, environment['github-deployment']) 33 | environment.outputs['github-deployment-url'] = deployment.url 34 | } 35 | } 36 | 37 | core.startGroup('Environments') 38 | core.info(JSON.stringify(environments, undefined, 2)) 39 | core.endGroup() 40 | return { 41 | json: environments.map((environment) => environment.outputs), 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /environment-matrix/tests/fixtures/test: -------------------------------------------------------------------------------- 1 | Test Fixture 2 | -------------------------------------------------------------------------------- /environment-matrix/tests/matcher.test.ts: -------------------------------------------------------------------------------- 1 | import { findEnvironmentsFromRules, matchEnvironment } from '../src/matcher.js' 2 | import { Rules } from '../src/rule.js' 3 | 4 | const rules: Rules = [ 5 | { 6 | pull_request: { 7 | head: '*/qa', 8 | base: '*/production', 9 | }, 10 | environments: [ 11 | { 12 | outputs: { 13 | overlay: 'pr', 14 | namespace: 'pr-2', 15 | }, 16 | }, 17 | ], 18 | }, 19 | { 20 | pull_request: { 21 | head: '**', 22 | base: '**', 23 | }, 24 | environments: [ 25 | { 26 | outputs: { 27 | overlay: 'pr', 28 | namespace: 'pr-1', 29 | }, 30 | }, 31 | ], 32 | }, 33 | { 34 | push: { 35 | ref: 'refs/heads/main', 36 | }, 37 | environments: [ 38 | { 39 | outputs: { 40 | overlay: 'development', 41 | namespace: 'development', 42 | }, 43 | }, 44 | ], 45 | }, 46 | ] 47 | 48 | test('pull_request with any branches', async () => { 49 | const context = { 50 | eventName: 'pull_request', 51 | payload: { 52 | pull_request: { 53 | number: 1, 54 | head: { ref: 'topic' }, 55 | base: { ref: 'main' }, 56 | }, 57 | }, 58 | ref: 'refs/pull/1/merge', 59 | } 60 | expect(await findEnvironmentsFromRules(rules, context)).toStrictEqual([ 61 | { 62 | outputs: { 63 | overlay: 'pr', 64 | namespace: 'pr-1', 65 | }, 66 | }, 67 | ]) 68 | }) 69 | 70 | test('pull_request with patterns', async () => { 71 | const context = { 72 | eventName: 'pull_request', 73 | payload: { 74 | pull_request: { 75 | number: 2, 76 | head: { ref: 'microservice/qa' }, 77 | base: { ref: 'microservice/production' }, 78 | }, 79 | }, 80 | ref: 'refs/pull/2/merge', 81 | } 82 | expect(await findEnvironmentsFromRules(rules, context)).toStrictEqual([ 83 | { 84 | outputs: { 85 | overlay: 'pr', 86 | namespace: 'pr-2', 87 | }, 88 | }, 89 | ]) 90 | }) 91 | 92 | test('push', async () => { 93 | const context = { 94 | eventName: 'push', 95 | payload: {}, 96 | ref: 'refs/heads/main', 97 | } 98 | expect(await findEnvironmentsFromRules(rules, context)).toStrictEqual([ 99 | { 100 | outputs: { 101 | overlay: 'development', 102 | namespace: 'development', 103 | }, 104 | }, 105 | ]) 106 | }) 107 | 108 | test('push with no match', async () => { 109 | const context = { 110 | eventName: 'push', 111 | payload: {}, 112 | ref: 'refs/tags/v1.0.0', 113 | } 114 | expect(await findEnvironmentsFromRules(rules, context)).toBeUndefined() 115 | }) 116 | 117 | describe('matchEnvironment', () => { 118 | describe('if-file-exists', () => { 119 | it('returns true if the file exists', async () => { 120 | const environment = { 121 | outputs: { 122 | namespace: 'pr-2', 123 | }, 124 | 'if-file-exists': 'tests/fixtures/*', 125 | } 126 | expect(await matchEnvironment(environment)).toBeTruthy() 127 | }) 128 | it('returns false if the file does not exist', async () => { 129 | const environment = { 130 | outputs: { 131 | namespace: 'pr-2', 132 | }, 133 | 'if-file-exists': 'tests/fixtures/not-found', 134 | } 135 | expect(await matchEnvironment(environment)).toBeFalsy() 136 | }) 137 | }) 138 | }) 139 | -------------------------------------------------------------------------------- /environment-matrix/tests/rule.test.ts: -------------------------------------------------------------------------------- 1 | import { Rules, parseRulesYAML } from '../src/rule.js' 2 | 3 | test('parse a valid YAML', () => { 4 | const yaml = ` 5 | - pull_request: 6 | base: '**' 7 | head: '**' 8 | environments: 9 | - github-deployment: 10 | environment: pr/pr-1/backend 11 | outputs: 12 | overlay: pr 13 | namespace: pr-1 14 | - push: 15 | ref: refs/heads/main 16 | environments: 17 | - outputs: 18 | overlay: development 19 | namespace: development 20 | ` 21 | expect(parseRulesYAML(yaml)).toStrictEqual([ 22 | { 23 | pull_request: { 24 | base: '**', 25 | head: '**', 26 | }, 27 | environments: [ 28 | { 29 | outputs: { 30 | overlay: 'pr', 31 | namespace: 'pr-1', 32 | }, 33 | 'github-deployment': { 34 | environment: 'pr/pr-1/backend', 35 | }, 36 | }, 37 | ], 38 | }, 39 | { 40 | push: { 41 | ref: 'refs/heads/main', 42 | }, 43 | environments: [ 44 | { 45 | outputs: { 46 | overlay: 'development', 47 | namespace: 'development', 48 | }, 49 | }, 50 | ], 51 | }, 52 | ]) 53 | }) 54 | 55 | test('parse an empty string', () => { 56 | expect(() => parseRulesYAML('')).toThrow(`invalid rules YAML: must be array`) 57 | }) 58 | 59 | describe('parse an invalid object', () => { 60 | test('missing field in pull_request', () => { 61 | const yaml = ` 62 | - pull_request: 63 | base: '**' 64 | environments: 65 | - outputs: 66 | overlay: pr 67 | namespace: pr-1 68 | ` 69 | expect(() => parseRulesYAML(yaml)).toThrow(`invalid rules YAML: /0/pull_request must have property 'head'`) 70 | }) 71 | 72 | test('missing field in environment', () => { 73 | const yaml = ` 74 | - pull_request: 75 | base: '**' 76 | head: '**' 77 | environments: 78 | - overlay: pr 79 | namespace: pr-1 80 | ` 81 | expect(() => parseRulesYAML(yaml)).toThrow(`invalid rules YAML: /0/environments/0 must have property 'outputs'`) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /environment-matrix/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import jest from 'eslint-plugin-jest'; 5 | import tseslint from 'typescript-eslint'; 6 | import typescriptEslintParser from "@typescript-eslint/parser"; 7 | 8 | export default tseslint.config( 9 | eslint.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | ...tseslint.configs.recommendedTypeChecked, 12 | { 13 | files: ['tests/**'], 14 | ...jest.configs['flat/recommended'], 15 | rules: { 16 | ...jest.configs['flat/recommended'].rules, 17 | }, 18 | }, 19 | { 20 | languageOptions: { 21 | parser: typescriptEslintParser, 22 | parserOptions: { 23 | project: true, 24 | }, 25 | }, 26 | }, 27 | ); 28 | -------------------------------------------------------------------------------- /get-service-versions/README.md: -------------------------------------------------------------------------------- 1 | # get-service-versions [![get-service-versions](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/get-service-versions.yaml/badge.svg)](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/get-service-versions.yaml) 2 | 3 | This is an action to get service versions (commit hash) pushed by `git-push-service` action. 4 | 5 | ## Getting Started 6 | 7 | ```yaml 8 | name: pr-namespace / get-service-versions 9 | 10 | on: 11 | pull_request: 12 | 13 | jobs: 14 | get-service-versions: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 10 17 | steps: 18 | - uses: quipper/monorepo-deploy-actions/get-service-versions@v1 19 | with: 20 | namespace: pr-${{ github.event.number }} 21 | repository: octocat/generated-manifests 22 | repository-token: ${{ steps.destination-repository-github-app.outputs.token }} 23 | ``` 24 | 25 | It assumes that the below name of prebuilt branch exists in the destination repository. 26 | 27 | ``` 28 | prebuilt/${source-repository}/${overlay} 29 | ``` 30 | 31 | ## Specification 32 | 33 | See [action.yaml](action.yaml). 34 | -------------------------------------------------------------------------------- /get-service-versions/action.yaml: -------------------------------------------------------------------------------- 1 | name: get-service-versions 2 | description: get the pushed service versions 3 | 4 | inputs: 5 | overlay: 6 | description: Name of overlay 7 | required: true 8 | namespace: 9 | description: Name of namespace 10 | required: true 11 | source-repository: 12 | description: Source repository 13 | required: true 14 | default: ${{ github.repository }} 15 | destination-repository: 16 | description: Destination repository 17 | required: true 18 | destination-repository-token: 19 | description: GitHub token for destination repository 20 | required: true 21 | default: ${{ github.token }} 22 | 23 | outputs: 24 | application-versions: 25 | description: 'JSON array of object containing keys: service, action, headRef, headSha' 26 | 27 | runs: 28 | using: 'node20' 29 | main: 'dist/index.js' 30 | -------------------------------------------------------------------------------- /get-service-versions/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | clearMocks: true, 5 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /get-service-versions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "get-service-versions", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "ncc build --source-map --license licenses.txt src/main.ts", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "1.11.1", 12 | "@actions/exec": "1.1.1", 13 | "@actions/glob": "0.5.0", 14 | "js-yaml": "4.1.0" 15 | }, 16 | "devDependencies": { 17 | "@types/js-yaml": "4.0.9" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /get-service-versions/src/application.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import * as core from '@actions/core' 3 | import * as glob from '@actions/glob' 4 | import { promises as fs } from 'fs' 5 | import * as yaml from 'js-yaml' 6 | 7 | export type ApplicationVersion = { 8 | service: string 9 | action: string 10 | headRef: string 11 | headSha: string 12 | } 13 | 14 | type PartialApplication = { 15 | metadata: { 16 | name: string 17 | annotations: { 18 | 'github.action': string 19 | 'github.head-ref': string | undefined 20 | 'github.head-sha': string | undefined 21 | } 22 | } 23 | } 24 | 25 | function assertIsApplication(o: unknown): asserts o is PartialApplication { 26 | assert(typeof o === 'object', 'must be an object') 27 | assert(o !== null, 'must not be null') 28 | assert('metadata' in o, 'must have metadata property') 29 | assert(typeof o.metadata === 'object', 'metadata must be an object') 30 | assert(o.metadata !== null, 'metadata must not be null') 31 | assert('annotations' in o.metadata, 'metadata must have annotations property') 32 | assert(typeof o.metadata.annotations === 'object', 'annotations must be an object') 33 | assert(o.metadata.annotations !== null, 'annotations must not be null') 34 | assert('github.action' in o.metadata.annotations, 'annotations must have github.action property') 35 | assert(typeof o.metadata.annotations['github.action'] === 'string', 'github.action must be a string') 36 | if ('github.head-sha' in o.metadata.annotations) { 37 | assert(typeof o.metadata.annotations['github.head-sha'] === 'string', 'github.head-sha must be a string') 38 | } 39 | } 40 | 41 | // expect applicationManifestPath to be applications/--.yaml 42 | const extractServiceNameFromApplicationName = (applicationName: string): string => { 43 | return applicationName.split('--')[1] 44 | } 45 | 46 | // parse ArgoCD's Application manifest, and parse annotations 47 | export const readApplication = async (applicationManifestPath: string): Promise => { 48 | let application 49 | try { 50 | const content = await fs.readFile(applicationManifestPath, 'utf-8') 51 | application = yaml.load(content) 52 | } catch (error) { 53 | if (error instanceof yaml.YAMLException) { 54 | core.warning(`Invalid application manifest ${applicationManifestPath}: ${error.toString()}`) 55 | return null 56 | } 57 | throw error 58 | } 59 | 60 | try { 61 | assertIsApplication(application) 62 | } catch (error) { 63 | if (error instanceof assert.AssertionError) { 64 | core.info(`Invalid application manifest ${applicationManifestPath}: ${error.message}`) 65 | return null 66 | } 67 | throw error 68 | } 69 | 70 | return { 71 | service: extractServiceNameFromApplicationName(application.metadata.name), 72 | action: application.metadata.annotations['github.action'], 73 | headRef: application.metadata.annotations['github.head-ref'] ?? 'main', 74 | headSha: application.metadata.annotations['github.head-sha'] ?? 'HEAD', 75 | } 76 | } 77 | 78 | export const listApplicationFiles = async (namespaceDirectory: string): Promise => { 79 | const globber = await glob.create(`${namespaceDirectory}/applications/*--*.yaml`, { matchDirectories: false }) 80 | return globber.glob() 81 | } 82 | -------------------------------------------------------------------------------- /get-service-versions/src/git.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as exec from '@actions/exec' 3 | import * as os from 'os' 4 | import * as path from 'path' 5 | import { promises as fs } from 'fs' 6 | 7 | type CheckoutOptions = { 8 | repository: string 9 | branch: string 10 | token: string 11 | } 12 | 13 | export const checkout = async (opts: CheckoutOptions) => { 14 | const cwd = await fs.mkdtemp(path.join(process.env.RUNNER_TEMP || os.tmpdir(), 'git-')) 15 | core.info(`Cloning ${opts.repository} into ${cwd}`) 16 | await exec.exec('git', ['version'], { cwd }) 17 | await exec.exec('git', ['init', '--initial-branch', opts.branch], { cwd }) 18 | await exec.exec('git', ['config', '--local', 'gc.auto', '0'], { cwd }) 19 | await exec.exec('git', ['remote', 'add', 'origin', `https://github.com/${opts.repository}`], { cwd }) 20 | const credentials = Buffer.from(`x-access-token:${opts.token}`).toString('base64') 21 | core.setSecret(credentials) 22 | await exec.exec( 23 | 'git', 24 | ['config', '--local', 'http.https://github.com/.extraheader', `AUTHORIZATION: basic ${credentials}`], 25 | { cwd }, 26 | ) 27 | await exec.exec( 28 | 'git', 29 | ['fetch', '--no-tags', '--depth=1', 'origin', `+refs/heads/${opts.branch}:refs/remotes/origin/${opts.branch}`], 30 | { cwd }, 31 | ) 32 | await exec.exec('git', ['checkout', opts.branch], { cwd }) 33 | return cwd 34 | } 35 | 36 | export const status = async (cwd: string): Promise => { 37 | const output = await exec.getExecOutput('git', ['status', '--porcelain'], { cwd }) 38 | return output.stdout.trim() 39 | } 40 | -------------------------------------------------------------------------------- /get-service-versions/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { run } from './run.js' 3 | 4 | const main = async (): Promise => { 5 | await run({ 6 | overlay: core.getInput('overlay', { required: true }), 7 | namespace: core.getInput('namespace', { required: true }), 8 | sourceRepository: core.getInput('source-repository', { required: true }), 9 | destinationRepository: core.getInput('destination-repository', { required: true }), 10 | destinationRepositoryToken: core.getInput('destination-repository-token', { required: true }), 11 | }) 12 | } 13 | 14 | main().catch((e: Error) => { 15 | core.setFailed(e) 16 | console.error(e) 17 | }) 18 | -------------------------------------------------------------------------------- /get-service-versions/src/run.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as git from './git.js' 3 | import { listApplicationFiles, readApplication, ApplicationVersion } from './application.js' 4 | 5 | type Inputs = { 6 | overlay: string 7 | namespace: string 8 | sourceRepository: string 9 | destinationRepository: string 10 | destinationRepositoryToken: string 11 | } 12 | 13 | type Outputs = ApplicationVersion[] 14 | 15 | export const run = async (inputs: Inputs): Promise => { 16 | const result = await getServiceVersions(inputs) 17 | 18 | if (result) { 19 | core.setOutput('application-versions', JSON.stringify(result)) 20 | } 21 | } 22 | 23 | const getServiceVersions = async (inputs: Inputs): Promise => { 24 | core.info(`Checking out the namespace branch`) 25 | const namespaceDirectory = await checkoutNamespaceBranch(inputs) 26 | core.debug(`Namespace directory: ${namespaceDirectory}`) 27 | 28 | const applicationFiles = await listApplicationFiles(namespaceDirectory) 29 | const serviceVersionsPromise = applicationFiles.map(async (file) => { 30 | core.debug(`Reading application file: ${file}`) 31 | const application = await readApplication(file) 32 | if (application) { 33 | core.info( 34 | `Service: ${application.service}, Action: ${application.action}, HeadRef: ${application.headRef}, HeadSha: ${application.headSha}`, 35 | ) 36 | return application 37 | } 38 | 39 | return null 40 | }) 41 | 42 | // return only non-null values 43 | const serviceVersions = (await Promise.all(serviceVersionsPromise)).filter( 44 | (serviceVersion): serviceVersion is ApplicationVersion => serviceVersion !== null, 45 | ) 46 | 47 | return serviceVersions 48 | } 49 | 50 | const checkoutNamespaceBranch = async (inputs: Inputs) => { 51 | const [, sourceRepositoryName] = inputs.sourceRepository.split('/') 52 | return await git.checkout({ 53 | repository: inputs.destinationRepository, 54 | branch: `ns/${sourceRepositoryName}/${inputs.overlay}/${inputs.namespace}`, 55 | token: inputs.destinationRepositoryToken, 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /get-service-versions/tests/aplication.test.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os' 2 | import * as path from 'path' 3 | import { promises as fs } from 'fs' 4 | import { listApplicationFiles, readApplication } from '../src/application.js' 5 | 6 | const createEmptyDirectory = async () => await fs.mkdtemp(path.join(os.tmpdir(), 'bootstrap-pull-request-')) 7 | 8 | describe('listApplicationFiles', () => { 9 | it('lists up the application files, not other files', async () => { 10 | const namespaceDirectory = await createEmptyDirectory() 11 | await fs.mkdir(`${namespaceDirectory}/applications`) 12 | const fileA = `${namespaceDirectory}/applications/pr-123--a.yaml` 13 | await fs.writeFile(fileA, applicationA) 14 | const fileB = `${namespaceDirectory}/applications/pr-123--b.yaml` 15 | await fs.writeFile(fileB, applicationB) 16 | await fs.writeFile(`${namespaceDirectory}/applications/other.yaml`, '') 17 | 18 | const result = await listApplicationFiles(namespaceDirectory) 19 | 20 | // sort the result to make the test deterministic 21 | 22 | expect(result.sort()).toStrictEqual([fileA, fileB]) 23 | }) 24 | }) 25 | 26 | describe('readApplication', () => { 27 | it('', async () => { 28 | const namespaceDirectory = await createEmptyDirectory() 29 | await fs.mkdir(`${namespaceDirectory}/applications`) 30 | await fs.writeFile(`${namespaceDirectory}/applications/pr-123--a.yaml`, applicationA) 31 | await fs.writeFile(`${namespaceDirectory}/applications/other.yaml`, '') 32 | 33 | const result = await readApplication(`${namespaceDirectory}/applications/pr-123--a.yaml`) 34 | expect(result).toStrictEqual({ 35 | service: 'a', 36 | action: 'git-push-service', 37 | headRef: 'current-ref-A', 38 | headSha: 'current-sha-A', 39 | }) 40 | }) 41 | }) 42 | 43 | const applicationA = `\ 44 | apiVersion: argoproj.io/v1alpha1 45 | kind: Application 46 | metadata: 47 | name: pr-123--a 48 | namespace: argocd 49 | finalizers: 50 | - resources-finalizer.argocd.argoproj.io 51 | annotations: 52 | github.action: git-push-service 53 | github.head-ref: current-ref-A 54 | github.head-sha: current-sha-A 55 | spec: {} 56 | ` 57 | 58 | const applicationB = `\ 59 | apiVersion: argoproj.io/v1alpha1 60 | kind: Application 61 | metadata: 62 | name: pr-123--b 63 | namespace: argocd 64 | finalizers: 65 | - resources-finalizer.argocd.argoproj.io 66 | annotations: 67 | github.action: git-push-service 68 | github.head-ref: current-ref-B 69 | github.head-sha: current-sha-B 70 | spec: {} 71 | ` 72 | -------------------------------------------------------------------------------- /get-service-versions/tests/fixtures/applications/pr-12345--a.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: pr-12345--a 5 | namespace: argocd 6 | finalizers: 7 | - resources-finalizer.argocd.argoproj.io 8 | annotations: 9 | github.head-ref: main 10 | github.head-sha: main-branch-sha 11 | github.action: git-push-service 12 | spec: 13 | project: source-repository 14 | source: 15 | repoURL: https://github.com/octocat/destination-repository.git 16 | targetRevision: pr/source-repository/pr-12345 17 | path: services/a 18 | destination: 19 | server: https://kubernetes.default.svc 20 | namespace: pr-12345 21 | syncPolicy: 22 | automated: 23 | prune: true 24 | -------------------------------------------------------------------------------- /get-service-versions/tests/fixtures/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: pr-12345 5 | -------------------------------------------------------------------------------- /get-service-versions/tests/fixtures/services/a/generated.yaml: -------------------------------------------------------------------------------- 1 | # fixture 2 | name: a 3 | namespace: pr-12345 4 | -------------------------------------------------------------------------------- /get-service-versions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /git-push-service/README.md: -------------------------------------------------------------------------------- 1 | # git-push-service 2 | 3 | This is an action to push a service manifest into a namespace branch. 4 | 5 | If you need to push a manifest into the namespace level, use [bootstrap-pull-request](../bootstrap-pull-request) action instead. 6 | 7 | ## Inputs 8 | 9 | | Name | Type | Description | 10 | | ------------------------- | ---------------- | ----------------------------------------------------------------------- | 11 | | `manifests` | multiline string | Glob pattern of file(s) | 12 | | `overlay` | string | Name of overlay | 13 | | `namespace` | string | Name of namespace | 14 | | `service` | string | Name of service | 15 | | `application-annotations` | multiline string | Annotations to add to an Application (default to empty) | 16 | | `destination-repository` | string | Destination repository | 17 | | `destination-branch` | string | Destination branch (default to `ns/${project}/${overlay}/${namespace}`) | 18 | | `update-via-pull-request` | boolean | Update a branch via a pull request (default to false) | 19 | | `token` | string | GitHub token (default to `github.token`) | 20 | 21 | If `manifests` do not match anything, this action does nothing. 22 | 23 | ## Outputs 24 | 25 | | Name | Type | Description | 26 | | --------------------------------- | ------ | ------------------------------------------------------------ | 27 | | `destination-pull-request-number` | number | Pull request number if created in the destination repository | 28 | | `destination-pull-request-url` | string | URL of pull request if created in the destination repository | 29 | 30 | ## Use-cases 31 | 32 | ### Push a manifest of a service 33 | 34 | To push a manifest of a service: 35 | 36 | ```yaml 37 | steps: 38 | - uses: int128/kustomize-action@v1 39 | id: kustomize 40 | with: 41 | kustomization: foo/kubernetes/overlays/develop/kustomization.yaml 42 | - uses: quipper/monorepo-deploy-actions/git-push-service@v1 43 | with: 44 | manifests: ${{ steps.kustomize.outputs.files }} 45 | overlay: develop 46 | namespace: develop 47 | service: foo 48 | ``` 49 | 50 | If mutiple manifests are given, this action concatenates them into a single file. 51 | 52 | It pushes the following files into a destination repository: 53 | 54 | ``` 55 | destination-repository (branch: ns/${project}/${overlay}/${namespace}) 56 | ├── applications 57 | | └── ${namespace}--${service}.yaml 58 | └── services 59 | └── ${service} 60 | └── generated.yaml 61 | ``` 62 | 63 | It generates an `Application` manifest with the following properties: 64 | 65 | - metadata 66 | - name: `${namespace}--${service}` 67 | - namespace: `argocd` 68 | - annotations 69 | - `github.head-ref`: Ref name of the current head branch 70 | - `github.head-sha`: SHA of the current head commit 71 | - `github.action`: `git-push-service` 72 | - source 73 | - repoURL: `https://github.com/${destination-repository}.git` 74 | - targetRevision: `ns/${project}/${overlay}/${namespace}` 75 | - path: `/services/${service}` 76 | - destination 77 | - namespace: `${namespace}` 78 | 79 | ### Push a manifest as a prebuilt one 80 | 81 | To push a manifest as a prebuilt manifest: 82 | 83 | ```yaml 84 | - uses: int128/kustomize-action@v1 85 | id: kustomize 86 | with: 87 | kustomization: foo/kubernetes/overlays/pr/kustomization.yaml 88 | - uses: quipper/monorepo-deploy-actions/git-push-service@v1 89 | with: 90 | manifests: ${{ steps.kustomize.outputs.directory }}/** 91 | overlay: pr 92 | service: foo 93 | destination-branch: prebuilt/source-repository/pr 94 | ``` 95 | 96 | It pushes the following file into a destination repository: 97 | 98 | ``` 99 | destination-repository (branch: prebuilt/source-repository/pr) 100 | └── services 101 | └── ${service} 102 | └── generated.yaml 103 | ``` 104 | 105 | You can build the prebuilt manifest using [bootstrap-pull-request action](../bootstrap-pull-request). 106 | 107 | ## Options 108 | 109 | ### Update strategy of namespace branch 110 | 111 | This action updates a namespace branch via a pull request. 112 | It brings the following benefits: 113 | 114 | - It would avoid the retries of fast-forward when many jobs are running concurrently 115 | - You can revert a change of manifest by clicking "Revert" button in a pull request 116 | 117 | You can turn on this feature by `update-via-pull-request` flag. 118 | -------------------------------------------------------------------------------- /git-push-service/action.yaml: -------------------------------------------------------------------------------- 1 | name: git-push-service 2 | description: push manifest(s) to deploy service(s) 3 | 4 | inputs: 5 | manifests: 6 | description: glob pattern(s) to manifest(s) in multi-line string 7 | required: true 8 | overlay: 9 | description: overlay name 10 | required: true 11 | namespace: 12 | description: namespace name 13 | required: true 14 | service: 15 | description: service name 16 | required: false 17 | application-annotations: 18 | description: annotations of application in form of NAME=VALUE 19 | required: false 20 | destination-repository: 21 | description: destination repository 22 | required: true 23 | destination-branch: 24 | description: destination branch (default to ns/REPOSITORY/OVERLAY/NAMESPACE) 25 | required: false 26 | update-via-pull-request: 27 | description: update a branch via a pull request 28 | required: true 29 | default: 'false' 30 | token: 31 | description: GitHub token 32 | required: true 33 | default: ${{ github.token }} 34 | current-head-ref: 35 | description: Ref of current head branch (For internal use) 36 | default: ${{ github.event.pull_request.head.ref || github.ref_name }} 37 | current-head-sha: 38 | description: SHA of current head commit (For internal use) 39 | default: ${{ github.event.pull_request.head.sha || github.sha }} 40 | 41 | outputs: 42 | destination-pull-request-number: 43 | description: pull request number if created 44 | destination-pull-request-url: 45 | description: URL of pull request if created 46 | 47 | runs: 48 | using: 'node20' 49 | main: 'dist/index.js' 50 | -------------------------------------------------------------------------------- /git-push-service/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | clearMocks: true, 5 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /git-push-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-push-service", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "ncc build --source-map --license licenses.txt src/main.ts", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "1.11.1", 12 | "@actions/exec": "1.1.1", 13 | "@actions/github": "6.0.1", 14 | "@actions/glob": "0.5.0", 15 | "@actions/io": "1.1.3", 16 | "js-yaml": "4.1.0" 17 | }, 18 | "devDependencies": { 19 | "@types/js-yaml": "4.0.9" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /git-push-service/src/arrange.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as fs from 'fs/promises' 3 | import * as io from '@actions/io' 4 | import * as path from 'path' 5 | import * as yaml from 'js-yaml' 6 | 7 | type Inputs = { 8 | workspace: string 9 | manifests: string[] 10 | namespace: string 11 | service: string 12 | project: string 13 | branch: string 14 | applicationAnnotations: string[] 15 | destinationRepository: string 16 | currentHeadRef: string 17 | currentHeadSha: string 18 | } 19 | 20 | export const writeManifests = async (inputs: Inputs): Promise => { 21 | await writeServiceManifest(inputs.manifests, `${inputs.workspace}/services/${inputs.service}/generated.yaml`) 22 | await writeApplicationManifest(inputs) 23 | } 24 | 25 | const writeServiceManifest = async (sourcePaths: string[], destinationPath: string) => { 26 | const sourceContents = await Promise.all( 27 | sourcePaths.map(async (manifestPath) => await fs.readFile(manifestPath, 'utf-8')), 28 | ) 29 | const concatManifest = sourceContents.join('\n---\n') 30 | core.info(`Writing the service manifest to ${destinationPath}`) 31 | await io.mkdirP(path.dirname(destinationPath)) 32 | await fs.writeFile(destinationPath, concatManifest) 33 | } 34 | 35 | const writeApplicationManifest = async (inputs: Inputs) => { 36 | const application = { 37 | apiVersion: 'argoproj.io/v1alpha1', 38 | kind: 'Application', 39 | metadata: { 40 | name: `${inputs.namespace}--${inputs.service}`, 41 | namespace: 'argocd', 42 | finalizers: ['resources-finalizer.argocd.argoproj.io'], 43 | annotations: { 44 | ...parseApplicationAnnotations(inputs.applicationAnnotations), 45 | 'github.head-ref': inputs.currentHeadRef, 46 | 'github.head-sha': inputs.currentHeadSha, 47 | 'github.action': 'git-push-service', 48 | }, 49 | }, 50 | spec: { 51 | project: inputs.project, 52 | source: { 53 | repoURL: `https://github.com/${inputs.destinationRepository}.git`, 54 | targetRevision: inputs.branch, 55 | path: `services/${inputs.service}`, 56 | }, 57 | destination: { 58 | server: `https://kubernetes.default.svc`, 59 | namespace: inputs.namespace, 60 | }, 61 | syncPolicy: { 62 | automated: { 63 | prune: true, 64 | }, 65 | }, 66 | }, 67 | } 68 | 69 | await io.mkdirP(`${inputs.workspace}/applications`) 70 | const destination = `${inputs.workspace}/applications/${application.metadata.name}.yaml` 71 | core.info(`Writing the application manifest to ${destination}`) 72 | await fs.writeFile(destination, yaml.dump(application)) 73 | } 74 | 75 | const parseApplicationAnnotations = (applicationAnnotations: string[]): Record => { 76 | const r: Record = {} 77 | for (const s of applicationAnnotations) { 78 | const k = s.substring(0, s.indexOf('=')) 79 | const v = s.substring(s.indexOf('=') + 1) 80 | r[k] = v 81 | } 82 | return r 83 | } 84 | -------------------------------------------------------------------------------- /git-push-service/src/git.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as exec from '@actions/exec' 3 | 4 | export const init = async (cwd: string, owner: string, repo: string, token: string): Promise => { 5 | await exec.exec('git', ['version'], { cwd }) 6 | await exec.exec('git', ['init'], { cwd }) 7 | await exec.exec('git', ['remote', 'add', 'origin', `https://github.com/${owner}/${repo}`], { cwd }) 8 | await exec.exec('git', ['config', '--local', 'gc.auto', '0'], { cwd }) 9 | 10 | const credentials = Buffer.from(`x-access-token:${token}`).toString('base64') 11 | core.setSecret(credentials) 12 | await exec.exec( 13 | 'git', 14 | ['config', '--local', 'http.https://github.com/.extraheader', `AUTHORIZATION: basic ${credentials}`], 15 | { 16 | cwd, 17 | }, 18 | ) 19 | } 20 | 21 | export const checkoutIfExist = async (cwd: string, branch: string): Promise => { 22 | const fetchCode = await exec.exec( 23 | 'git', 24 | [ 25 | '-c', 26 | 'protocol.version=2', 27 | 'fetch', 28 | '--no-tags', 29 | '--prune', 30 | '--no-recurse-submodules', 31 | '--depth=1', 32 | 'origin', 33 | `+refs/heads/${branch}:refs/remotes/origin/${branch}`, 34 | ], 35 | { cwd, ignoreReturnCode: true }, 36 | ) 37 | if (fetchCode === 0) { 38 | await exec.exec('git', ['branch', '--list', '--remote', `origin/${branch}`], { cwd }) 39 | await exec.exec('git', ['checkout', '--progress', '--force', branch], { cwd }) 40 | } 41 | return fetchCode 42 | } 43 | 44 | export const status = async (cwd: string): Promise => { 45 | const output = await exec.getExecOutput('git', ['status', '--porcelain'], { cwd }) 46 | return output.stdout.trim() 47 | } 48 | 49 | export const commit = async (cwd: string, message: string): Promise => { 50 | await exec.exec('git', ['add', '.'], { cwd }) 51 | await exec.exec('git', ['config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'], { cwd }) 52 | await exec.exec('git', ['config', 'user.name', 'github-actions[bot]'], { cwd }) 53 | await exec.exec('git', ['commit', '-m', message], { cwd }) 54 | } 55 | 56 | export const pushByFastForward = async (cwd: string, branch: string): Promise => { 57 | return await exec.exec('git', ['push', 'origin', `HEAD:refs/heads/${branch}`], { cwd, ignoreReturnCode: true }) 58 | } 59 | 60 | export const deleteRef = async (cwd: string, ref: string): Promise => 61 | await exec.exec('git', ['push', '--delete', 'origin', ref], { cwd, ignoreReturnCode: true }) 62 | -------------------------------------------------------------------------------- /git-push-service/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { run } from '../src/run.js' 3 | 4 | const main = async (): Promise => { 5 | const outputs = await run({ 6 | manifests: core.getInput('manifests', { required: true }), 7 | overlay: core.getInput('overlay', { required: true }), 8 | namespace: core.getInput('namespace', { required: true }), 9 | service: core.getInput('service', { required: true }), 10 | applicationAnnotations: core.getMultilineInput('application-annotations'), 11 | destinationRepository: core.getInput('destination-repository', { required: true }), 12 | destinationBranch: core.getInput('destination-branch'), 13 | updateViaPullRequest: core.getBooleanInput('update-via-pull-request', { required: true }), 14 | token: core.getInput('token', { required: true }), 15 | currentHeadRef: core.getInput('current-head-ref', { required: true }), 16 | currentHeadSha: core.getInput('current-head-sha', { required: true }), 17 | }) 18 | if (outputs?.destinationPullRequest !== undefined) { 19 | core.setOutput('destination-pull-request-number', outputs.destinationPullRequest.number) 20 | core.setOutput('destination-pull-request-url', outputs.destinationPullRequest.url) 21 | } 22 | } 23 | 24 | main().catch((e: Error) => { 25 | core.setFailed(e) 26 | console.error(e) 27 | }) 28 | -------------------------------------------------------------------------------- /git-push-service/src/pull.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import * as git from './git.js' 4 | import { catchHttpStatus, retry } from './retry.js' 5 | 6 | type Inputs = { 7 | owner: string 8 | repo: string 9 | title: string 10 | body: string 11 | branch: string 12 | workspace: string 13 | project: string 14 | namespace: string 15 | service: string 16 | token: string 17 | } 18 | 19 | type PullRequest = { 20 | number: number 21 | url: string 22 | } 23 | 24 | export const updateBranchByPullRequest = async (inputs: Inputs): Promise => { 25 | const topicBranch = `git-push-service--${inputs.namespace}--${inputs.service}--${Date.now()}` 26 | const code = await core.group(`push branch ${topicBranch}`, () => 27 | git.pushByFastForward(inputs.workspace, topicBranch), 28 | ) 29 | if (code > 0) { 30 | return new Error(`failed to push branch ${topicBranch} by fast-forward`) 31 | } 32 | 33 | const octokit = github.getOctokit(inputs.token) 34 | core.info(`creating a pull request from ${topicBranch} into ${inputs.branch}`) 35 | const { data: pull } = await octokit.rest.pulls.create({ 36 | owner: inputs.owner, 37 | repo: inputs.repo, 38 | base: inputs.branch, 39 | head: topicBranch, 40 | title: inputs.title, 41 | body: inputs.body, 42 | }) 43 | core.info(`created ${pull.html_url}`) 44 | 45 | core.info(`adding labels to #${pull.number}`) 46 | await octokit.rest.issues.addLabels({ 47 | owner: inputs.owner, 48 | repo: inputs.repo, 49 | issue_number: pull.number, 50 | labels: [`project:${inputs.project}`, `namespace:${inputs.namespace}`, `service:${inputs.service}`], 51 | }) 52 | core.info(`added labels to #${pull.number}`) 53 | 54 | // GitHub merge API returns 405 in the following cases: 55 | // - "Base branch was modified" error. 56 | // https://github.community/t/merging-via-rest-api-returns-405-base-branch-was-modified-review-and-try-the-merge-again/13787 57 | // Just retry the API invocation. 58 | // - The pull request is conflicted. 59 | // Need to fetch and recreate a pull request again. 60 | // 61 | // We cannot distinguish the error because GitHub returns 405 for both. 62 | // First this retries the merge API several times. 63 | // If the error is not resolved, this returns the error to retry in the caller side. 64 | try { 65 | return await catchHttpStatus(405, async () => { 66 | return await retry( 67 | async () => 68 | await catchHttpStatus(405, async () => { 69 | const { data: merge } = await octokit.rest.pulls.merge({ 70 | owner: inputs.owner, 71 | repo: inputs.repo, 72 | pull_number: pull.number, 73 | merge_method: 'squash', 74 | }) 75 | core.info(`merged ${pull.html_url} as ${merge.sha}`) 76 | return { 77 | number: pull.number, 78 | url: pull.html_url, 79 | } 80 | }), 81 | { 82 | maxAttempts: 10, 83 | waitMillisecond: 1000, 84 | }, 85 | ) 86 | }) 87 | } finally { 88 | await git.deleteRef(inputs.workspace, topicBranch) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /git-push-service/src/retry.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | type RetrySpec = { 4 | maxAttempts: number 5 | waitMillisecond: number 6 | } 7 | 8 | export const retry = async (f: () => Promise, spec: RetrySpec): Promise => { 9 | for (let i = 1; ; i++) { 10 | const result = await f() 11 | if (!(result instanceof Error)) { 12 | return result 13 | } 14 | if (i >= spec.maxAttempts) { 15 | throw result 16 | } 17 | 18 | // Here don't use the exponential algorithm, 19 | // because a namespace branch will be updated from many jobs concurrently 20 | const wait = i + Math.random() * spec.waitMillisecond 21 | core.warning(`retry after ${wait} ms: ${String(result)}`) 22 | await new Promise((resolve) => setTimeout(resolve, wait)) 23 | } 24 | } 25 | 26 | export const catchHttpStatus = async (status: number, f: () => Promise): Promise => { 27 | try { 28 | return await f() 29 | } catch (e) { 30 | if (isRequestError(e) && e.status === status) { 31 | return e // retry 32 | } 33 | throw e 34 | } 35 | } 36 | 37 | type RequestError = Error & { status: number } 38 | 39 | const isRequestError = (error: unknown): error is RequestError => 40 | error instanceof Error && 'status' in error && typeof error.status === 'number' 41 | -------------------------------------------------------------------------------- /git-push-service/tests/arrange.test.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os' 2 | import * as fs from 'fs/promises' 3 | import * as path from 'path' 4 | import { writeManifests } from '../src/arrange.js' 5 | 6 | const readContent = async (f: string) => await fs.readFile(f, 'utf-8') 7 | 8 | it('writes the service and application manifests', async () => { 9 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-action-')) 10 | 11 | await writeManifests({ 12 | workspace, 13 | manifests: [path.join(__dirname, `fixtures/a/generated.yaml`)], 14 | branch: `ns/project/overlay/namespace`, 15 | namespace: 'namespace', 16 | service: 'a', 17 | project: 'project', 18 | applicationAnnotations: ['example=foo'], 19 | destinationRepository: 'octocat/manifests', 20 | currentHeadRef: 'refs/heads/main', 21 | currentHeadSha: '1234567890abcdef', 22 | }) 23 | 24 | expect(await readContent(path.join(workspace, `applications/namespace--a.yaml`))).toBe(applicationA) 25 | expect(await readContent(path.join(workspace, `services/a/generated.yaml`))).toBe( 26 | await readContent(path.join(__dirname, `fixtures/a/generated.yaml`)), 27 | ) 28 | }) 29 | 30 | it('concatenates the service manifests if multiple are given', async () => { 31 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-action-')) 32 | 33 | await writeManifests({ 34 | workspace, 35 | manifests: [path.join(__dirname, `fixtures/a/generated.yaml`), path.join(__dirname, `fixtures/b/generated.yaml`)], 36 | branch: `ns/project/overlay/namespace`, 37 | namespace: 'namespace', 38 | service: 'service', 39 | project: 'project', 40 | applicationAnnotations: ['example=foo'], 41 | destinationRepository: 'octocat/manifests', 42 | currentHeadRef: 'refs/heads/main', 43 | currentHeadSha: '1234567890abcdef', 44 | }) 45 | 46 | expect(await readContent(path.join(workspace, `services/service/generated.yaml`))).toBe(`\ 47 | ${await readContent(path.join(__dirname, `fixtures/a/generated.yaml`))} 48 | --- 49 | ${await readContent(path.join(__dirname, `fixtures/b/generated.yaml`))}`) 50 | }) 51 | 52 | it('overwrites if a file exists', async () => { 53 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-action-')) 54 | 55 | // put dummy files 56 | await fs.mkdir(path.join(workspace, `applications`)) 57 | await fs.writeFile(path.join(workspace, `applications/namespace--a.yaml`), 'fixture-application-manifest') 58 | await fs.mkdir(path.join(workspace, `services`)) 59 | await fs.mkdir(path.join(workspace, `services/a`)) 60 | await fs.writeFile(path.join(workspace, `services/a/generated.yaml`), 'fixture-generated-manifest') 61 | 62 | await writeManifests({ 63 | workspace, 64 | manifests: [path.join(__dirname, `fixtures/a/generated.yaml`)], 65 | branch: `ns/project/overlay/namespace`, 66 | namespace: 'namespace', 67 | service: 'a', 68 | project: 'project', 69 | applicationAnnotations: ['example=foo'], 70 | destinationRepository: 'octocat/manifests', 71 | currentHeadRef: 'refs/heads/main', 72 | currentHeadSha: '1234567890abcdef', 73 | }) 74 | 75 | expect(await readContent(path.join(workspace, `applications/namespace--a.yaml`))).toBe(applicationA) 76 | expect(await readContent(path.join(workspace, `services/a/generated.yaml`))).toBe( 77 | await readContent(path.join(__dirname, `fixtures/a/generated.yaml`)), 78 | ) 79 | }) 80 | 81 | const applicationA = `\ 82 | apiVersion: argoproj.io/v1alpha1 83 | kind: Application 84 | metadata: 85 | name: namespace--a 86 | namespace: argocd 87 | finalizers: 88 | - resources-finalizer.argocd.argoproj.io 89 | annotations: 90 | example: foo 91 | github.head-ref: refs/heads/main 92 | github.head-sha: 1234567890abcdef 93 | github.action: git-push-service 94 | spec: 95 | project: project 96 | source: 97 | repoURL: https://github.com/octocat/manifests.git 98 | targetRevision: ns/project/overlay/namespace 99 | path: services/a 100 | destination: 101 | server: https://kubernetes.default.svc 102 | namespace: namespace 103 | syncPolicy: 104 | automated: 105 | prune: true 106 | ` 107 | -------------------------------------------------------------------------------- /git-push-service/tests/fixtures/a/generated.yaml: -------------------------------------------------------------------------------- 1 | # fixture 2 | name: a 3 | -------------------------------------------------------------------------------- /git-push-service/tests/fixtures/b/generated.yaml: -------------------------------------------------------------------------------- 1 | # fixture 2 | name: b 3 | -------------------------------------------------------------------------------- /git-push-service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /git-push-services-patch/README.md: -------------------------------------------------------------------------------- 1 | # git-push-services-patch 2 | 3 | This is an action to push a patch to services in a namespace. 4 | 5 | ## Inputs 6 | 7 | | Name | Type | Description | 8 | | ------------------------ | ---------------- | ---------------------------------------- | 9 | | `patch` | string | Path to a patch | 10 | | `operation` | string | Either `add` or `delete` | 11 | | `overlay` | string | Name of overlay | 12 | | `namespace` | string | Name of namespace | 13 | | `services` | multiline string | Names of services to include (optional), If not specified, targets all services | 14 | | `exclude-services` | multiline string | Names of services to exclude (optional) | 15 | | `destination-repository` | string | Destination repository | 16 | | `token` | string | GitHub token (default to `github.token`) | 17 | 18 | ### Note about `services` and `exclude-services` 19 | 20 | If you define both `services` and `exclude-services`, the workflow will only apply services only both filters satisfies. 21 | 22 | ## Outputs 23 | 24 | Nothing. 25 | 26 | ## Use-case: nightly stop 27 | 28 | For cost saving, we can temporarily stop all pods in night. 29 | 30 | ### Scale in 31 | 32 | Here is an example of workflow. 33 | 34 | ```yaml 35 | name: scale-in-services-daily 36 | 37 | on: 38 | schedule: 39 | - cron: '0 13 * * 1-5' # 22:00 JST weekday 40 | 41 | jobs: 42 | develop: 43 | runs-on: ubuntu-latest 44 | timeout-minutes: 10 45 | steps: 46 | - uses: actions/checkout@v3 47 | - uses: quipper/monorepo-deploy-actions/git-push-services-patch@v1 48 | with: 49 | patch: nightly-stop-patch/kustomization.yaml 50 | operation: add 51 | overlay: develop 52 | namespace: develop 53 | ``` 54 | 55 | You need create a patch such as [this example of kustomization.yaml](tests/fixtures/kustomization.yaml). 56 | 57 | When the workflow runs, this action pushes the patch into the all services in destination repository. 58 | 59 | ``` 60 | destination-repository (branch: ns/${project}/${overlay}/${namespace}) 61 | ├── applications 62 | └── services 63 | └── ${service} 64 | ├── generated.yaml 65 | └── kustomization.yaml 66 | ``` 67 | 68 | ### Scale out 69 | 70 | To delete the patch, create a workflow with `delete` operation. 71 | 72 | ```yaml 73 | name: scale-in-services-daily 74 | 75 | on: 76 | schedule: 77 | - cron: '0 23 * * 0-4' # 08:00 JST weekday 78 | 79 | jobs: 80 | develop: 81 | runs-on: ubuntu-latest 82 | timeout-minutes: 10 83 | steps: 84 | - uses: actions/checkout@v3 85 | - uses: quipper/monorepo-deploy-actions/git-push-services-patch@v1 86 | with: 87 | patch: nightly-stop-patch/kustomization.yaml 88 | operation: delete 89 | overlay: develop 90 | namespace: develop 91 | ``` 92 | 93 | ### Exclude specific service(s) 94 | 95 | You can exclude specific service(s). 96 | 97 | ```yaml 98 | - uses: quipper/monorepo-deploy-actions/git-push-services-patch@v1 99 | with: 100 | patch: nightly-stop-patch/kustomization.yaml 101 | operation: add 102 | overlay: develop 103 | namespace: develop 104 | exclude-services: | 105 | some-backend 106 | some-frontend 107 | ``` 108 | -------------------------------------------------------------------------------- /git-push-services-patch/action.yaml: -------------------------------------------------------------------------------- 1 | name: git-push-services-patch 2 | description: action to push a patch to all services in a namespace 3 | 4 | inputs: 5 | patch: 6 | description: path to a patch 7 | required: true 8 | operation: 9 | description: add or delete 10 | required: true 11 | overlay: 12 | description: overlay name 13 | required: true 14 | namespace: 15 | description: namespace name 16 | required: true 17 | exclude-services: 18 | description: services to exclude (optional) 19 | required: false 20 | destination-repository: 21 | description: destination repository 22 | required: true 23 | token: 24 | description: GitHub token 25 | required: true 26 | default: ${{ github.token }} 27 | 28 | runs: 29 | using: 'node20' 30 | main: 'dist/index.js' 31 | -------------------------------------------------------------------------------- /git-push-services-patch/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | clearMocks: true, 5 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /git-push-services-patch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-push-services-patch", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "ncc build --source-map --license licenses.txt src/main.ts", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "1.11.1", 12 | "@actions/exec": "1.1.1", 13 | "@actions/github": "6.0.1", 14 | "@actions/io": "1.1.3" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /git-push-services-patch/src/git.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as exec from '@actions/exec' 3 | 4 | export const init = async (cwd: string, owner: string, repo: string, token: string): Promise => { 5 | await exec.exec('git', ['version'], { cwd }) 6 | await exec.exec('git', ['init'], { cwd }) 7 | await exec.exec('git', ['remote', 'add', 'origin', `https://github.com/${owner}/${repo}`], { cwd }) 8 | await exec.exec('git', ['config', '--local', 'gc.auto', '0'], { cwd }) 9 | 10 | const credentials = Buffer.from(`x-access-token:${token}`).toString('base64') 11 | core.setSecret(credentials) 12 | await exec.exec( 13 | 'git', 14 | ['config', '--local', 'http.https://github.com/.extraheader', `AUTHORIZATION: basic ${credentials}`], 15 | { 16 | cwd, 17 | }, 18 | ) 19 | } 20 | 21 | export const checkout = async (cwd: string, branch: string): Promise => { 22 | await exec.exec( 23 | 'git', 24 | [ 25 | '-c', 26 | 'protocol.version=2', 27 | 'fetch', 28 | '--no-tags', 29 | '--prune', 30 | '--no-recurse-submodules', 31 | '--depth=1', 32 | 'origin', 33 | `+refs/heads/${branch}:refs/remotes/origin/${branch}`, 34 | ], 35 | { cwd }, 36 | ) 37 | await exec.exec('git', ['branch', '--list', '--remote', `origin/${branch}`], { cwd }) 38 | await exec.exec('git', ['checkout', '--progress', '--force', branch], { cwd }) 39 | } 40 | 41 | export const status = async (cwd: string): Promise => { 42 | const output = await exec.getExecOutput('git', ['status', '--porcelain'], { cwd }) 43 | return output.stdout.trim() 44 | } 45 | 46 | export const commit = async (cwd: string, message: string): Promise => { 47 | await exec.exec('git', ['add', '.'], { cwd }) 48 | await exec.exec('git', ['config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'], { cwd }) 49 | await exec.exec('git', ['config', 'user.name', 'github-actions[bot]'], { cwd }) 50 | await exec.exec('git', ['commit', '-m', message], { cwd }) 51 | } 52 | 53 | export const pushByFastForward = async (cwd: string, branch: string): Promise => { 54 | return await exec.exec('git', ['push', 'origin', `HEAD:refs/heads/${branch}`], { cwd, ignoreReturnCode: true }) 55 | } 56 | -------------------------------------------------------------------------------- /git-push-services-patch/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { operationOf, run } from './run.js' 3 | 4 | const main = async (): Promise => { 5 | await run({ 6 | patch: core.getInput('patch', { required: true }), 7 | operation: operationOf(core.getInput('operation', { required: true })), 8 | overlay: core.getInput('overlay', { required: true }), 9 | namespace: core.getInput('namespace', { required: true }), 10 | services: core.getMultilineInput('services'), 11 | excludeServices: core.getMultilineInput('exclude-services'), 12 | destinationRepository: core.getInput('destination-repository', { required: true }), 13 | token: core.getInput('token', { required: true }), 14 | }) 15 | } 16 | 17 | main().catch((e: Error) => { 18 | core.setFailed(e) 19 | console.error(e) 20 | }) 21 | -------------------------------------------------------------------------------- /git-push-services-patch/src/patch.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { promises as fs } from 'fs' 3 | import * as io from '@actions/io' 4 | import * as path from 'path' 5 | 6 | type Inputs = { 7 | workspace: string 8 | patch: string 9 | services: Set 10 | excludeServices: Set 11 | } 12 | 13 | export const addToServices = async (inputs: Inputs): Promise => { 14 | const services = (await readdirOrEmpty(`${inputs.workspace}/services`)) 15 | .filter((e) => e.isDirectory()) 16 | .map((e) => e.name) 17 | core.info(`found ${services.length} service(s)`) 18 | 19 | for (const service of services) { 20 | if (shouldSkipService(service, inputs.services, inputs.excludeServices)) { 21 | continue 22 | } 23 | 24 | const serviceDirectory = `${inputs.workspace}/services/${service}` 25 | core.info(`copying the patch into ${serviceDirectory}`) 26 | await io.cp(inputs.patch, serviceDirectory, { force: true }) 27 | } 28 | } 29 | 30 | export const deleteFromServices = async (inputs: Inputs) => { 31 | const patchBasename = path.basename(inputs.patch) 32 | 33 | const services = (await readdirOrEmpty(`${inputs.workspace}/services`)) 34 | .filter((e) => e.isDirectory()) 35 | .map((e) => e.name) 36 | core.info(`found ${services.length} service(s)`) 37 | 38 | for (const service of services) { 39 | if (shouldSkipService(service, inputs.services, inputs.excludeServices)) { 40 | continue 41 | } 42 | 43 | const patchPath = `${inputs.workspace}/services/${service}/${patchBasename}` 44 | core.info(`removing ${patchPath}`) 45 | await io.rmRF(patchPath) 46 | } 47 | } 48 | 49 | const shouldSkipService = (service: string, services: Set, excludeServices: Set) => { 50 | if (services.size > 0 && !services.has(service)) { 51 | core.info(`skipping ${service} because it is not specified`) 52 | return true 53 | } 54 | 55 | if (excludeServices.has(service)) { 56 | core.info(`excluded service ${service}`) 57 | return true 58 | } 59 | 60 | return false 61 | } 62 | 63 | const readdirOrEmpty = async (dir: string) => { 64 | try { 65 | return await fs.readdir(dir, { withFileTypes: true }) 66 | } catch (error) { 67 | if (typeof error === 'object' && error !== null && 'code' in error) { 68 | const e = error as { code: string } 69 | if (e.code === 'ENOENT') { 70 | return [] 71 | } 72 | } 73 | throw error 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /git-push-services-patch/src/retry.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | interface RetrySpec { 4 | maxAttempts: number 5 | waitMillisecond: number 6 | } 7 | 8 | export const retry = async (f: () => Promise, spec: RetrySpec): Promise => { 9 | for (let i = 1; ; i++) { 10 | const result = await f() 11 | if (!(result instanceof Error)) { 12 | return result 13 | } 14 | if (i >= spec.maxAttempts) { 15 | throw result 16 | } 17 | 18 | // Here don't use the exponential algorithm, 19 | // because a namespace branch will be updated from many jobs concurrently 20 | const wait = i + Math.random() * spec.waitMillisecond 21 | core.warning(`retry after ${wait} ms: ${String(result)}`) 22 | await new Promise((resolve) => setTimeout(resolve, wait)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /git-push-services-patch/src/run.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os' 2 | import { promises as fs } from 'fs' 3 | import * as path from 'path' 4 | import * as core from '@actions/core' 5 | import * as git from './git.js' 6 | import * as github from '@actions/github' 7 | import * as patch from './patch.js' 8 | import { retry } from './retry.js' 9 | 10 | type Inputs = { 11 | patch: string 12 | operation: Operation 13 | overlay: string 14 | namespace: string 15 | services: string[] 16 | excludeServices: string[] 17 | destinationRepository: string 18 | token: string 19 | } 20 | 21 | type Operation = 'add' | 'delete' 22 | 23 | export const operationOf = (s: string): Operation => { 24 | if (s === 'add') { 25 | return s 26 | } 27 | if (s === 'delete') { 28 | return s 29 | } 30 | throw new Error(`unknown operation ${s}`) 31 | } 32 | 33 | export const run = async (inputs: Inputs): Promise => 34 | await retry(async () => await push(inputs), { 35 | maxAttempts: 50, 36 | waitMillisecond: 10000, 37 | }) 38 | 39 | const push = async (inputs: Inputs): Promise => { 40 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-services-patch-')) 41 | core.info(`created workspace at ${workspace}`) 42 | 43 | const [owner, repo] = inputs.destinationRepository.split('/') 44 | const project = github.context.repo.repo 45 | const branch = `ns/${project}/${inputs.overlay}/${inputs.namespace}` 46 | 47 | core.startGroup(`checkout branch ${branch}`) 48 | await git.init(workspace, owner, repo, inputs.token) 49 | await git.checkout(workspace, branch) 50 | core.endGroup() 51 | 52 | if (inputs.operation === 'add') { 53 | await patch.addToServices({ 54 | workspace, 55 | patch: inputs.patch, 56 | services: new Set(inputs.services), 57 | excludeServices: new Set(inputs.excludeServices), 58 | }) 59 | } else if (inputs.operation === 'delete') { 60 | await patch.deleteFromServices({ 61 | workspace, 62 | patch: inputs.patch, 63 | services: new Set(inputs.services), 64 | excludeServices: new Set(inputs.excludeServices), 65 | }) 66 | } 67 | 68 | const status = await git.status(workspace) 69 | if (status === '') { 70 | core.info('nothing to commit') 71 | return 72 | } 73 | return await core.group(`push branch ${branch}`, async () => { 74 | const message = `${commitMessage(inputs.namespace, inputs.operation)}\n\n${commitMessageFooter}` 75 | await git.commit(workspace, message) 76 | const code = await git.pushByFastForward(workspace, branch) 77 | if (code > 0) { 78 | return new Error(`failed to push branch ${branch} by fast-forward`) 79 | } 80 | }) 81 | } 82 | 83 | const commitMessage = (namespace: string, operation: Operation) => { 84 | if (operation === 'add') { 85 | return `Add patch into ${namespace}` 86 | } 87 | return `Delete patch from ${namespace}` 88 | } 89 | 90 | const commitMessageFooter = [ 91 | 'git-push-services-patch', 92 | `${github.context.payload.repository?.html_url ?? ''}/commit/${github.context.sha}`, 93 | `${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/actions/runs/${github.context.runId}`, 94 | ].join('\n') 95 | -------------------------------------------------------------------------------- /git-push-services-patch/tests/fixtures/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - generated.yaml 3 | patchesJson6902: 4 | - target: 5 | group: apps 6 | version: v1 7 | kind: Deployment 8 | name: '.*' 9 | patch: | 10 | - op: replace 11 | path: /spec/replicas 12 | value: 0 13 | -------------------------------------------------------------------------------- /git-push-services-patch/tests/patch.test.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs' 2 | import * as os from 'os' 3 | import * as path from 'path' 4 | import { addToServices, deleteFromServices } from '../src/patch.js' 5 | 6 | const patch = path.join(__dirname, 'fixtures/kustomization.yaml') 7 | 8 | describe('addToServices', () => { 9 | test('if there are several services', async () => { 10 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-services-patch-')) 11 | await fs.mkdir(path.join(workspace, `services`)) 12 | await fs.mkdir(path.join(workspace, `services/a`)) 13 | await fs.mkdir(path.join(workspace, `services/b`)) 14 | 15 | await expect( 16 | addToServices({ workspace, patch, services: new Set(), excludeServices: new Set() }), 17 | ).resolves.toBeUndefined() 18 | 19 | await fs.access(path.join(workspace, `services/a/kustomization.yaml`)) 20 | await fs.access(path.join(workspace, `services/b/kustomization.yaml`)) 21 | }) 22 | 23 | test('exclude a service', async () => { 24 | const excludeServices = new Set(['a']) 25 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-services-patch-')) 26 | await fs.mkdir(path.join(workspace, `services`)) 27 | await fs.mkdir(path.join(workspace, `services/a`)) 28 | await fs.mkdir(path.join(workspace, `services/b`)) 29 | 30 | await expect(addToServices({ workspace, patch, services: new Set(), excludeServices })).resolves.toBeUndefined() 31 | 32 | await expect(fs.access(path.join(workspace, `services/a/kustomization.yaml`))).rejects.toThrow() 33 | await fs.access(path.join(workspace, `services/b/kustomization.yaml`)) 34 | }) 35 | 36 | test('specify a service', async () => { 37 | const services = new Set(['a']) 38 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-services-patch-')) 39 | await fs.mkdir(path.join(workspace, `services`)) 40 | await fs.mkdir(path.join(workspace, `services/a`)) 41 | await fs.mkdir(path.join(workspace, `services/b`)) 42 | 43 | await expect(addToServices({ workspace, patch, services, excludeServices: new Set() })).resolves.toBeUndefined() 44 | 45 | await fs.access(path.join(workspace, `services/a/kustomization.yaml`)) 46 | await expect(fs.access(path.join(workspace, `services/b/kustomization.yaml`))).rejects.toThrow() 47 | }) 48 | 49 | test('specify and exclude services', async () => { 50 | const services = new Set(['a', 'b']) 51 | const excludeServices = new Set(['b', 'c']) 52 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-services-patch-')) 53 | await fs.mkdir(path.join(workspace, `services`)) 54 | await fs.mkdir(path.join(workspace, `services/a`)) 55 | await fs.mkdir(path.join(workspace, `services/b`)) 56 | await fs.mkdir(path.join(workspace, `services/c`)) 57 | 58 | await expect(addToServices({ workspace, patch, services, excludeServices })).resolves.toBeUndefined() 59 | 60 | await fs.access(path.join(workspace, `services/a/kustomization.yaml`)) 61 | await expect(fs.access(path.join(workspace, `services/b/kustomization.yaml`))).rejects.toThrow() 62 | await expect(fs.access(path.join(workspace, `services/c/kustomization.yaml`))).rejects.toThrow() 63 | }) 64 | 65 | test('if empty directory', async () => { 66 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-services-patch-')) 67 | await expect( 68 | addToServices({ workspace, patch, services: new Set(), excludeServices: new Set() }), 69 | ).resolves.toBeUndefined() 70 | }) 71 | }) 72 | 73 | describe('deleteFromServices', () => { 74 | test('if there are several services', async () => { 75 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-services-patch-')) 76 | await fs.mkdir(path.join(workspace, `services`)) 77 | await fs.mkdir(path.join(workspace, `services/a`)) 78 | await fs.mkdir(path.join(workspace, `services/b`)) 79 | await fs.writeFile(path.join(workspace, `services/a/kustomization.yaml`), 'dummy') 80 | await fs.writeFile(path.join(workspace, `services/b/kustomization.yaml`), 'dummy') 81 | 82 | await expect( 83 | deleteFromServices({ workspace, patch, services: new Set(), excludeServices: new Set() }), 84 | ).resolves.toBeUndefined() 85 | 86 | await expect(fs.access(path.join(workspace, `services/a/kustomization.yaml`))).rejects.toThrow() 87 | await expect(fs.access(path.join(workspace, `services/b/kustomization.yaml`))).rejects.toThrow() 88 | }) 89 | 90 | test('exclude a service', async () => { 91 | const excludeServices = new Set(['b']) 92 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-services-patch-')) 93 | await fs.mkdir(path.join(workspace, `services`)) 94 | await fs.mkdir(path.join(workspace, `services/a`)) 95 | await fs.mkdir(path.join(workspace, `services/b`)) 96 | await fs.writeFile(path.join(workspace, `services/a/kustomization.yaml`), 'dummy') 97 | await fs.writeFile(path.join(workspace, `services/b/kustomization.yaml`), 'dummy') 98 | 99 | await expect( 100 | deleteFromServices({ workspace, patch, services: new Set(), excludeServices }), 101 | ).resolves.toBeUndefined() 102 | 103 | await expect(fs.access(path.join(workspace, `services/a/kustomization.yaml`))).rejects.toThrow() 104 | await fs.access(path.join(workspace, `services/b/kustomization.yaml`)) 105 | }) 106 | 107 | test('if empty directory', async () => { 108 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'git-push-services-patch-')) 109 | await expect( 110 | deleteFromServices({ workspace, patch, services: new Set(), excludeServices: new Set() }), 111 | ).resolves.toBeUndefined() 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /git-push-services-patch/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /open-backport-pull-request/README.md: -------------------------------------------------------------------------------- 1 | # open-backport-pull-request 2 | 3 | This is an action to open Backport Pull Requests from a specific branch. 4 | 5 | This is useful in branching strategies like Gitflow, where a hotfix to the `main` branch needs to be backported to the `develop` branch afterwards. 6 | 7 | ## Getting Started 8 | 9 | To create a backport pull request when a branch is changed: 10 | 11 | ```yaml 12 | on: 13 | push: 14 | branches: 15 | - 'main' 16 | - '*/main' 17 | 18 | jobs: 19 | backport: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - id: backport 23 | name: Open Backport Pull Request 24 | uses: quipper/monorepo-deploy-actions/open-backport-pull-request@v1 25 | with: 26 | base-branch: develop 27 | ``` 28 | 29 | This action creates a working branch from the latest commit of head branch. 30 | It creates a pull request from the working branch, because the head branch is protected typically. 31 | When the pull request is conflicted, you can edit the working branch on GitHub. 32 | 33 | ### Skip workflow runs 34 | 35 | You can set `skip-ci` to skip workflow runs for the backport pull request. 36 | See https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs for details. 37 | 38 | ### Automatically merge the pull request 39 | 40 | You can set `merge-pull-request` to automatically merge the pull request. 41 | 42 | You will get `merged` output to check if the pull request is merged. 43 | If this action could not merge due to a conflict or branch protection rule, it requests a review to the actor. 44 | 45 | ## Specification 46 | 47 | ### Inputs 48 | 49 | | Name | Default | Description | 50 | | -------------------- | ----------------- | -------------------------------------------- | 51 | | `github-token` | `github.token` | GitHub token used for opening a pull request | 52 | | `base-branch` | - | Base branch of the pull request | 53 | | `head-branch` | `github.ref_name` | Head branch of the pull request | 54 | | `skip-ci` | false | Add `[skip ci]` to the commit message | 55 | | `merge-pull-request` | false | Try to merge the pull request | 56 | | `pull-request-title` | \*1 | Title of the pull request | 57 | | `pull-request-body` | \*1 | Body of the pull request | 58 | 59 | \*1: 60 | See [action.yaml](./action.yaml) for the default value. 61 | `HEAD_BRANCH` and `BASE_BRANCH` are replaced with the actual branch names. 62 | 63 | ### Outputs 64 | 65 | | Name | Description | 66 | | ------------------ | --------------------------------------------- | 67 | | `pull-request-url` | URL of the opened Pull Request | 68 | | `base-branch` | The base branch of the opened Pull Request | 69 | | `head-branch` | The head branch of the opened Pull Request | 70 | | `merged` | If the pull request is merged, returns `true` | 71 | -------------------------------------------------------------------------------- /open-backport-pull-request/action.yaml: -------------------------------------------------------------------------------- 1 | name: open-backport-pull-request 2 | description: Open backport Pull Requests 3 | inputs: 4 | github-token: 5 | description: GitHub token used for opening a Pull Request 6 | default: ${{ github.token }} 7 | base-branch: 8 | description: The base branch for a Pull Request 9 | required: true 10 | head-branch: 11 | description: The head branch for a Pull Request 12 | required: true 13 | default: ${{ github.ref_name }} 14 | skip-ci: 15 | description: Add `[skip ci]` to the commit message 16 | required: true 17 | default: 'false' 18 | merge-pull-request: 19 | description: Try to merge the pull request 20 | required: true 21 | default: 'false' 22 | pull-request-title: 23 | description: The title of the Pull Request 24 | required: true 25 | default: Backport from HEAD_BRANCH to BASE_BRANCH 26 | pull-request-body: 27 | description: The body of the Pull Request 28 | required: true 29 | default: This is an automated backport from HEAD_BRANCH to BASE_BRANCH. 30 | outputs: 31 | pull-request-url: 32 | description: The URL of the opened Pull Request 33 | base-branch: 34 | description: The base branch of the opened Pull Request 35 | head-branch: 36 | description: The head branch of the opened Pull Request 37 | merged: 38 | description: If the pull request is merged, returns `true` 39 | runs: 40 | using: 'node20' 41 | main: 'dist/index.js' 42 | -------------------------------------------------------------------------------- /open-backport-pull-request/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | clearMocks: true, 5 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /open-backport-pull-request/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-backport-pull-request-action", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Open Backport Pull Request action", 6 | "type": "module", 7 | "scripts": { 8 | "build": "ncc build --source-map --license licenses.txt src/main.ts", 9 | "test": "jest" 10 | }, 11 | "dependencies": { 12 | "@actions/core": "1.11.1", 13 | "@actions/github": "6.0.1", 14 | "@octokit/plugin-retry": "6.0.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /open-backport-pull-request/src/format.ts: -------------------------------------------------------------------------------- 1 | import { Context } from './github.js' 2 | 3 | type CommitMessageParams = { 4 | headBranch: string 5 | baseBranch: string 6 | skipCI: boolean 7 | } 8 | 9 | export const getCommitMessage = (params: CommitMessageParams, context: Context): string => { 10 | // https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs 11 | const skipCI = params.skipCI ? ' [skip ci]' : '' 12 | 13 | return `Backport from ${params.headBranch} into ${params.baseBranch}${skipCI} 14 | 15 | ${workflowUrl(context)}` 16 | } 17 | 18 | type PullRequestParams = { 19 | headBranch: string 20 | baseBranch: string 21 | pullRequestTitle: string 22 | pullRequestBody: string 23 | } 24 | 25 | export const getPullRequestTitle = (params: PullRequestParams): string => 26 | params.pullRequestTitle.replaceAll('HEAD_BRANCH', params.headBranch).replaceAll('BASE_BRANCH', params.baseBranch) 27 | 28 | export const getPullRequestBody = (params: PullRequestParams, context: Context): string => 29 | `${params.pullRequestBody.replaceAll('HEAD_BRANCH', params.headBranch).replaceAll('BASE_BRANCH', params.baseBranch)} 30 | 31 | ---- 32 | ${workflowUrl(context)}` 33 | 34 | const workflowUrl = (context: Context) => 35 | `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` 36 | -------------------------------------------------------------------------------- /open-backport-pull-request/src/github.ts: -------------------------------------------------------------------------------- 1 | import * as github from '@actions/github' 2 | import * as octokitPluginRetry from '@octokit/plugin-retry' 3 | 4 | export type Octokit = ReturnType 5 | 6 | export const getOctokit = (token: string) => github.getOctokit(token, {}, octokitPluginRetry.retry) 7 | 8 | export type Context = { 9 | actor: string 10 | repo: { 11 | owner: string 12 | repo: string 13 | } 14 | runId: number 15 | } 16 | -------------------------------------------------------------------------------- /open-backport-pull-request/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import { run } from './run.js' 4 | 5 | const main = async (): Promise => { 6 | const inputs = { 7 | githubToken: core.getInput('github-token', { required: true }), 8 | headBranch: core.getInput('head-branch', { required: true }), 9 | baseBranch: core.getInput('base-branch', { required: true }), 10 | skipCI: core.getBooleanInput('skip-ci', { required: true }), 11 | mergePullRequest: core.getBooleanInput('merge-pull-request', { required: true }), 12 | pullRequestBody: core.getInput('pull-request-body', { required: false }), 13 | pullRequestTitle: core.getInput('pull-request-title', { required: false }), 14 | } 15 | const outputs = await run(inputs, github.context) 16 | if (outputs) { 17 | core.setOutput('pull-request-url', outputs.pullRequestUrl) 18 | core.setOutput('base-branch', outputs.baseBranch) 19 | core.setOutput('head-branch', outputs.headBranch) 20 | core.setOutput('merged', outputs.merged) 21 | } 22 | } 23 | 24 | main().catch((e: Error) => { 25 | core.setFailed(e) 26 | console.error(e) 27 | }) 28 | -------------------------------------------------------------------------------- /open-backport-pull-request/src/run.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as format from './format.js' 3 | import { Context, getOctokit, Octokit } from './github.js' 4 | 5 | type Inputs = { 6 | githubToken: string 7 | headBranch: string 8 | baseBranch: string 9 | skipCI: boolean 10 | mergePullRequest: boolean 11 | pullRequestTitle: string 12 | pullRequestBody: string 13 | } 14 | 15 | type Outputs = { 16 | pullRequestUrl: string 17 | baseBranch: string 18 | headBranch: string 19 | merged: boolean 20 | } 21 | 22 | export const run = async (inputs: Inputs, context: Context): Promise => { 23 | const octokit = getOctokit(inputs.githubToken) 24 | 25 | core.info(`Comparing ${inputs.baseBranch} and ${inputs.headBranch} branch`) 26 | const { data: compare } = await octokit.rest.repos.compareCommitsWithBasehead({ 27 | owner: context.repo.owner, 28 | repo: context.repo.repo, 29 | basehead: `${inputs.baseBranch}...${inputs.headBranch}`, 30 | }) 31 | if (compare.files === undefined || compare.files.length === 0) { 32 | core.info(`No changes between ${inputs.baseBranch} and ${inputs.headBranch} branch. Do nothing.`) 33 | return 34 | } 35 | core.info(`There are ${compare.files.length} changes between ${inputs.baseBranch} and ${inputs.headBranch} branch.`) 36 | 37 | return await openPullRequest( 38 | { 39 | headBranch: inputs.headBranch, 40 | baseBranch: inputs.baseBranch, 41 | skipCI: inputs.skipCI, 42 | mergePullRequest: inputs.mergePullRequest, 43 | commitMessage: format.getCommitMessage(inputs, context), 44 | pullRequestTitle: format.getPullRequestTitle(inputs), 45 | pullRequestBody: format.getPullRequestBody(inputs, context), 46 | context, 47 | }, 48 | octokit, 49 | ) 50 | } 51 | 52 | type Backport = { 53 | headBranch: string 54 | baseBranch: string 55 | skipCI: boolean 56 | mergePullRequest: boolean 57 | commitMessage: string 58 | pullRequestTitle: string 59 | pullRequestBody: string 60 | context: Context 61 | } 62 | 63 | const openPullRequest = async (params: Backport, octokit: Octokit): Promise => { 64 | // Add an empty commit onto the head commit. 65 | // If a required check is set against the base branch and the head commit is failing, we cannot merge it. 66 | const { data: headBranch } = await octokit.rest.repos.getBranch({ 67 | owner: params.context.repo.owner, 68 | repo: params.context.repo.repo, 69 | branch: params.headBranch, 70 | }) 71 | core.info(`Creating an empty commit on the head ${headBranch.commit.sha}`) 72 | const { data: workingCommit } = await octokit.rest.git.createCommit({ 73 | owner: params.context.repo.owner, 74 | repo: params.context.repo.repo, 75 | parents: [headBranch.commit.sha], 76 | tree: headBranch.commit.commit.tree.sha, 77 | message: params.commitMessage, 78 | }) 79 | 80 | // Create a working branch so that we can edit it if conflicted. 81 | // Generally, the head branch is protected and cannot be edited. 82 | const workingBranch = `backport-${params.headBranch.replaceAll('/', '-')}-${Date.now()}` 83 | core.info(`Creating a working branch ${workingBranch} from ${workingCommit.sha}`) 84 | await octokit.rest.git.createRef({ 85 | owner: params.context.repo.owner, 86 | repo: params.context.repo.repo, 87 | ref: `refs/heads/${workingBranch}`, 88 | sha: workingCommit.sha, 89 | }) 90 | 91 | core.info(`Creating a pull request ${workingBranch} -> ${params.baseBranch}`) 92 | const { data: pull } = await octokit.rest.pulls.create({ 93 | owner: params.context.repo.owner, 94 | repo: params.context.repo.repo, 95 | head: workingBranch, 96 | base: params.baseBranch, 97 | title: params.pullRequestTitle, 98 | body: params.pullRequestBody, 99 | }) 100 | core.info(`Created ${pull.html_url}`) 101 | 102 | if (params.mergePullRequest) { 103 | core.info(`Trying to merge ${pull.html_url}`) 104 | try { 105 | // When merging a pull request, GitHub API sometimes returns 405 "Base branch was modified" error. 106 | // octokit/plugin-retry will retry it. 107 | // https://github.community/t/merging-via-rest-api-returns-405-base-branch-was-modified-review-and-try-the-merge-again/13787 108 | const { data: merged } = await octokit.rest.pulls.merge({ 109 | owner: params.context.repo.owner, 110 | repo: params.context.repo.repo, 111 | pull_number: pull.number, 112 | merge_method: 'merge', 113 | }) 114 | core.info(`Merged ${pull.html_url} as ${merged.sha}`) 115 | // If merged, return immediately without any reviewer or assignee. 116 | return { 117 | pullRequestUrl: pull.html_url, 118 | baseBranch: params.baseBranch, 119 | headBranch: params.headBranch, 120 | merged: true, 121 | } 122 | } catch (e) { 123 | core.warning(`Could not merge ${pull.html_url}: ${String(e)}`) 124 | } 125 | } 126 | 127 | core.info(`Requesting a review to ${params.context.actor}`) 128 | try { 129 | await octokit.rest.pulls.requestReviewers({ 130 | owner: params.context.repo.owner, 131 | repo: params.context.repo.repo, 132 | pull_number: pull.number, 133 | reviewers: [params.context.actor], 134 | }) 135 | } catch (e) { 136 | core.info(`Could not request a review to ${params.context.actor}: ${String(e)}`) 137 | } 138 | 139 | core.info(`Adding ${params.context.actor} to assignees`) 140 | try { 141 | await octokit.rest.issues.addAssignees({ 142 | owner: params.context.repo.owner, 143 | repo: params.context.repo.repo, 144 | issue_number: pull.number, 145 | assignees: [params.context.actor], 146 | }) 147 | } catch (e) { 148 | core.info(`Could not assign ${params.context.actor}: ${String(e)}`) 149 | } 150 | 151 | return { 152 | pullRequestUrl: pull.html_url, 153 | baseBranch: params.baseBranch, 154 | headBranch: params.headBranch, 155 | merged: false, 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /open-backport-pull-request/tests/format.test.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '../src/github.js' 2 | import { getCommitMessage, getPullRequestBody, getPullRequestTitle } from '../src/format.js' 3 | 4 | const context: Context = { 5 | actor: 'octocat', 6 | repo: { 7 | owner: 'owner', 8 | repo: 'repo', 9 | }, 10 | runId: 1, 11 | } 12 | 13 | describe('getCommitMessage', () => { 14 | it('should return the commit message', () => { 15 | const params = { 16 | headBranch: 'production', 17 | baseBranch: 'main', 18 | skipCI: false, 19 | } 20 | expect(getCommitMessage(params, context)).toEqual(`Backport from production into main 21 | 22 | https://github.com/owner/repo/actions/runs/1`) 23 | }) 24 | 25 | it('should return the commit message with skip ci', () => { 26 | const params = { 27 | headBranch: 'production', 28 | baseBranch: 'main', 29 | skipCI: true, 30 | } 31 | expect(getCommitMessage(params, context)).toEqual(`Backport from production into main [skip ci] 32 | 33 | https://github.com/owner/repo/actions/runs/1`) 34 | }) 35 | }) 36 | 37 | describe('getPullRequestTitle', () => { 38 | it('should return the pull request title', () => { 39 | const params = { 40 | headBranch: 'production', 41 | baseBranch: 'main', 42 | pullRequestTitle: 'Backport from HEAD_BRANCH into BASE_BRANCH', 43 | pullRequestBody: '', 44 | } 45 | expect(getPullRequestTitle(params)).toEqual('Backport from production into main') 46 | }) 47 | }) 48 | 49 | describe('getPullRequestBody', () => { 50 | it('should return the pull request body', () => { 51 | const params = { 52 | headBranch: 'production', 53 | baseBranch: 'main', 54 | pullRequestTitle: '', 55 | pullRequestBody: 'This is a backport pull request from HEAD_BRANCH into BASE_BRANCH', 56 | } 57 | expect(getPullRequestBody(params, context)).toEqual(`This is a backport pull request from production into main 58 | 59 | ---- 60 | https://github.com/owner/repo/actions/runs/1`) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /open-backport-pull-request/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "monorepo-deploy-actions", 4 | "license": "MIT", 5 | "type": "module", 6 | "devDependencies": { 7 | "@eslint/js": "9.28.0", 8 | "@tsconfig/recommended": "1.0.8", 9 | "@types/jest": "29.5.14", 10 | "@types/node": "20.19.0", 11 | "@typescript-eslint/parser": "8.34.0", 12 | "@vercel/ncc": "0.38.3", 13 | "eslint": "9.28.0", 14 | "eslint-plugin-jest": "28.13.3", 15 | "jest": "30.0.0", 16 | "pnpm": "10.12.1", 17 | "prettier": "3.5.3", 18 | "ts-jest": "29.4.0", 19 | "typescript": "5.8.3", 20 | "typescript-eslint": "8.34.0" 21 | }, 22 | "scripts": { 23 | "format": "prettier --write **/*.ts", 24 | "lint": "eslint --fix **/*.ts" 25 | }, 26 | "engines": { 27 | "node": "20.x" 28 | }, 29 | "packageManager": "pnpm@10.12.1" 30 | } 31 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - '*' 3 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/README.md: -------------------------------------------------------------------------------- 1 | # resolve-aws-secret-version 2 | 3 | This is an action to resolve the secret versions of manifests. 4 | It supports the following operators: 5 | 6 | - `AWSSecret` resource of https://github.com/mumoshu/aws-secret-operator 7 | - `ExternalSecret` resource of https://github.com/external-secrets/external-secrets 8 | 9 | ## Inputs 10 | 11 | | Name | Type | Description | 12 | | ----------- | ---------------- | ------------------------------ | 13 | | `manifests` | Multiline string | Glob pattern(s) to manifest(s) | 14 | 15 | ## Example 16 | 17 | Here is an example workflow: 18 | 19 | ```yaml 20 | steps: 21 | - uses: int128/kustomize-action@v1 22 | id: kustomize 23 | with: 24 | kustomization: path/to/kustomization.yaml 25 | - uses: quipper/monorepo-deploy-actions/resolve-aws-secret-version@v1 26 | id: resolve-secret 27 | with: 28 | manifests: ${{ steps.kustomize.outputs.directory }}/**/*.yaml 29 | ``` 30 | 31 | If no manifest file is matched, this action does nothing. 32 | 33 | When the below manifest is given, 34 | 35 | ```yaml 36 | apiVersion: apps/v1 37 | kind: Deployment 38 | metadata: 39 | name: microservice 40 | spec: 41 | template: 42 | spec: 43 | containers: 44 | - image: nginx 45 | envFrom: 46 | - secretRef: 47 | name: microservice-${AWS_SECRETS_MANAGER_VERSION_ID} 48 | --- 49 | apiVersion: external-secrets.io/v1beta1 50 | kind: ExternalSecret 51 | metadata: 52 | name: microservice-${AWS_SECRETS_MANAGER_VERSION_ID} 53 | spec: 54 | dataFrom: 55 | - extract: 56 | key: microservice/develop 57 | version: uuid/${AWS_SECRETS_MANAGER_VERSION_ID} 58 | ``` 59 | 60 | This action replaces a placeholder in `version` field with the current version ID. 61 | In this example, it replaces `${AWS_SECRETS_MANAGER_VERSION_ID}` with the current version ID. 62 | 63 | This action replaces the placeholders by the following rules: 64 | 65 | - If a manifest does not contain any `ExternalSecret` or `AWSSecret`, do nothing. 66 | - It replaces the placeholder if `version` field of `ExternalSecret` has a placeholder in form of `uuid/${...}`. 67 | - It replaces the placeholder if `versionId` field of `AWSSecret` has a placeholder in form of `${...}`. 68 | 69 | Finally this action writes the below manifest: 70 | 71 | ```yaml 72 | apiVersion: apps/v1 73 | kind: Deployment 74 | metadata: 75 | name: microservice 76 | spec: 77 | template: 78 | spec: 79 | containers: 80 | - image: nginx 81 | envFrom: 82 | - secretRef: 83 | name: microservice-c7ea50c5-b2be-4970-bf90-2237bef3b4cf 84 | --- 85 | apiVersion: external-secrets.io/v1beta1 86 | kind: ExternalSecret 87 | metadata: 88 | name: microservice-c7ea50c5-b2be-4970-bf90-2237bef3b4cf 89 | spec: 90 | dataFrom: 91 | - extract: 92 | key: microservice/develop 93 | version: uuid/c7ea50c5-b2be-4970-bf90-2237bef3b4cf 94 | ``` 95 | 96 | This action accepts multi-line paths. 97 | If 2 or more manifests are given, this action processes them and sets the output paths as a multi-line string. 98 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/action.yaml: -------------------------------------------------------------------------------- 1 | name: resolve-aws-secret-version 2 | description: resolve AWSSecret versionId placeholders in a manifest 3 | inputs: 4 | manifests: 5 | description: glob pattern(s) to manifest(s) 6 | required: false 7 | runs: 8 | using: 'node20' 9 | main: 'dist/index.js' 10 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | clearMocks: true, 5 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "resolve-aws-secret-version", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "ncc build --source-map --license licenses.txt src/main.ts", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "1.11.1", 12 | "@actions/glob": "0.5.0", 13 | "@aws-sdk/client-secrets-manager": "3.828.0", 14 | "js-yaml": "4.1.0" 15 | }, 16 | "devDependencies": { 17 | "@types/js-yaml": "4.0.9", 18 | "aws-sdk-client-mock": "4.1.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/src/awsSecretsManager.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { ListSecretVersionIdsCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager' 3 | 4 | // get the current version id for the secret 5 | export const getCurrentVersionId = async (secretId: string): Promise => { 6 | const client = new SecretsManagerClient({}) 7 | const listCommand = new ListSecretVersionIdsCommand({ SecretId: secretId }) 8 | let listOutput 9 | try { 10 | listOutput = await client.send(listCommand) 11 | } catch (error) { 12 | throw new Error(`could not find the secret ${secretId} from AWS Secrets Manager: ${String(error)}`) 13 | } 14 | assert(listOutput.Versions !== undefined) 15 | const currentVersion = listOutput.Versions.find((version) => 16 | version.VersionStages?.some((stage) => stage === 'AWSCURRENT'), 17 | ) 18 | assert(currentVersion !== undefined) 19 | assert(currentVersion.VersionId !== undefined) 20 | return currentVersion.VersionId 21 | } 22 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/src/kubernetes.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | 3 | export type KubernetesObject = { 4 | kind: string 5 | } 6 | 7 | export const isKubernetesObject = (a: unknown): a is KubernetesObject => 8 | typeof a === 'object' && a !== null && 'kind' in a && typeof a.kind === 'string' 9 | 10 | export type KubernetesAWSSecret = KubernetesObject & { 11 | metadata: { 12 | name: string 13 | } 14 | spec: { 15 | stringDataFrom: { 16 | secretsManagerSecretRef: { 17 | secretId: string 18 | versionId: string 19 | } 20 | } 21 | } 22 | } 23 | 24 | export function assertKubernetesAWSSecret(a: KubernetesObject): asserts a is KubernetesAWSSecret { 25 | assertHasField(a, 'AWSSecret', 'metadata') 26 | assertHasField(a.metadata, 'AWSSecret', 'name') 27 | assert(typeof a.metadata.name === 'string', `metadata.name must be a string`) 28 | 29 | assertHasField(a, 'AWSSecret', 'spec') 30 | assertHasField(a.spec, 'AWSSecret', 'stringDataFrom') 31 | assertHasField(a.spec.stringDataFrom, 'AWSSecret', 'secretsManagerSecretRef') 32 | 33 | assertHasField(a.spec.stringDataFrom.secretsManagerSecretRef, 'AWSSecret', 'secretId') 34 | assert(typeof a.spec.stringDataFrom.secretsManagerSecretRef.secretId === 'string') 35 | 36 | assertHasField(a.spec.stringDataFrom.secretsManagerSecretRef, 'AWSSecret', 'versionId') 37 | assert(typeof a.spec.stringDataFrom.secretsManagerSecretRef.versionId === 'string') 38 | } 39 | 40 | export type KubernetesExternalSecret = KubernetesObject & { 41 | metadata: { 42 | name: string 43 | } 44 | spec: { 45 | dataFrom: { 46 | extract: { 47 | key: string 48 | version: string 49 | } 50 | }[] 51 | } 52 | } 53 | 54 | export function assertKubernetesExternalSecret(a: KubernetesObject): asserts a is KubernetesExternalSecret { 55 | assertHasField(a, 'ExternalSecret', 'metadata') 56 | assertHasField(a.metadata, 'ExternalSecret', 'name') 57 | assert(typeof a.metadata.name === 'string', `metadata.name must be a string`) 58 | 59 | assertHasField(a, 'ExternalSecret', 'spec') 60 | assertHasField(a.spec, 'ExternalSecret', 'dataFrom') 61 | assert(Array.isArray(a.spec.dataFrom), `spec.dataFrom.extract must be an array`) 62 | 63 | for (const dataFrom of a.spec.dataFrom) { 64 | assertHasField(dataFrom, 'ExternalSecret', 'extract') 65 | assertHasField(dataFrom.extract, 'ExternalSecret', 'key') 66 | assert(typeof dataFrom.extract.key === 'string', `spec.dataFrom.extract.key must be a string`) 67 | assertHasField(dataFrom.extract, 'ExternalSecret', 'version') 68 | assert(typeof dataFrom.extract.version === 'string', `spec.dataFrom.extract.version must be a string`) 69 | } 70 | } 71 | 72 | function assertHasField(o: unknown, kind: string, key: K): asserts o is Record { 73 | assert(typeof o === 'object' && o !== null, `must be an object`) 74 | assert(key in o, `${kind} must have ${key} field`) 75 | } 76 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { run } from './run.js' 3 | 4 | const main = async () => { 5 | const inputs = { 6 | manifests: core.getInput('manifests'), 7 | } 8 | await run(inputs) 9 | } 10 | 11 | main().catch((e: Error) => { 12 | core.setFailed(e) 13 | console.error(e) 14 | }) 15 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/src/resolve.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises' 2 | import * as core from '@actions/core' 3 | import * as yaml from 'js-yaml' 4 | import { assertKubernetesAWSSecret, assertKubernetesExternalSecret, isKubernetesObject } from './kubernetes.js' 5 | import assert from 'assert' 6 | 7 | type AWSSecretsManager = { 8 | getCurrentVersionId(secretId: string): Promise 9 | } 10 | 11 | export const updateManifest = async (manifestPath: string, manager: AWSSecretsManager): Promise => { 12 | core.info(`Reading the manifest: ${manifestPath}`) 13 | const inputManifest = await fs.readFile(manifestPath, 'utf-8') 14 | const outputManifest = await replaceSecretVersionIds(inputManifest, manager) 15 | 16 | core.info(`Writing the manifest: ${manifestPath}`) 17 | await fs.writeFile(manifestPath, outputManifest, { encoding: 'utf-8' }) 18 | } 19 | 20 | export const replaceSecretVersionIds = async (manifest: string, manager: AWSSecretsManager): Promise => { 21 | const awsSecrets = findAWSSecretsFromManifest(manifest) 22 | let resolved = manifest 23 | for (const awsSecret of awsSecrets) { 24 | core.info( 25 | `Finding the current versionId of ${awsSecret.kind}: name=${awsSecret.name}, secretId=${awsSecret.secretId}`, 26 | ) 27 | const currentVersionId = await manager.getCurrentVersionId(awsSecret.secretId) 28 | core.info(`Replacing ${awsSecret.versionIdPlaceholder} with the current versionId ${currentVersionId}`) 29 | resolved = resolved.replaceAll(awsSecret.versionIdPlaceholder, currentVersionId) 30 | } 31 | return resolved 32 | } 33 | 34 | type AWSSecret = { 35 | kind: string 36 | name: string 37 | secretId: string 38 | versionIdPlaceholder: string 39 | } 40 | 41 | const findAWSSecretsFromManifest = (manifest: string): AWSSecret[] => { 42 | const secrets: AWSSecret[] = [] 43 | const documents = yaml.loadAll(manifest) 44 | for (const doc of documents) { 45 | if (!isKubernetesObject(doc)) { 46 | continue 47 | } 48 | 49 | if (doc.kind === 'AWSSecret') { 50 | try { 51 | assertKubernetesAWSSecret(doc) 52 | } catch (error) { 53 | if (error instanceof assert.AssertionError) { 54 | core.error(`Invalid AWSSecret object: ${JSON.stringify(doc)}`) 55 | } 56 | throw error 57 | } 58 | const versionIdPlaceholder = doc.spec.stringDataFrom.secretsManagerSecretRef.versionId 59 | if (!versionIdPlaceholder.startsWith('${') || !versionIdPlaceholder.endsWith('}')) { 60 | continue 61 | } 62 | secrets.push({ 63 | kind: doc.kind, 64 | name: doc.metadata.name, 65 | secretId: doc.spec.stringDataFrom.secretsManagerSecretRef.secretId, 66 | versionIdPlaceholder, 67 | }) 68 | } else if (doc.kind === 'ExternalSecret') { 69 | try { 70 | assertKubernetesExternalSecret(doc) 71 | } catch (error) { 72 | if (error instanceof assert.AssertionError) { 73 | core.error(`Invalid ExternalSecret object: ${JSON.stringify(doc)}`) 74 | } 75 | throw error 76 | } 77 | for (const dataFrom of doc.spec.dataFrom) { 78 | const version = dataFrom.extract.version 79 | if (!version.startsWith('uuid/${') || !version.endsWith('}')) { 80 | continue 81 | } 82 | const versionIdPlaceholder = version.substring('uuid/'.length) 83 | secrets.push({ 84 | kind: doc.kind, 85 | name: doc.metadata.name, 86 | secretId: dataFrom.extract.key, 87 | versionIdPlaceholder, 88 | }) 89 | } 90 | } 91 | } 92 | return secrets 93 | } 94 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/src/run.ts: -------------------------------------------------------------------------------- 1 | import * as glob from '@actions/glob' 2 | import * as awsSecretsManager from './awsSecretsManager' 3 | import { updateManifest } from './resolve.js' 4 | 5 | type Inputs = { 6 | manifests: string 7 | } 8 | 9 | export const run = async (inputs: Inputs): Promise => { 10 | const manifests = await glob.create(inputs.manifests, { matchDirectories: false }) 11 | for await (const manifest of manifests.globGenerator()) { 12 | await updateManifest(manifest, awsSecretsManager) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/tests/awsSecretsManager.test.ts: -------------------------------------------------------------------------------- 1 | import { mockClient } from 'aws-sdk-client-mock' 2 | import { ListSecretVersionIdsCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager' 3 | import * as awsSecretsManager from '../src/awsSecretsManager.js' 4 | 5 | const secretsManagerMock = mockClient(SecretsManagerClient) 6 | 7 | it('returns the current version id', async () => { 8 | secretsManagerMock.on(ListSecretVersionIdsCommand, { SecretId: 'microservice/develop' }).resolves( 9 | // this is an actual payload of the command: 10 | // $ aws secretsmanager list-secret-version-ids --secret-id microservice/develop 11 | { 12 | Versions: [ 13 | { 14 | VersionId: 'cf06c560-f2c1-4150-a322-0d2120f7c12e', 15 | VersionStages: ['AWSCURRENT'], 16 | LastAccessedDate: new Date('2021-05-03T09:00:00+09:00'), 17 | CreatedDate: new Date('2021-02-03T19:20:03.322000+09:00'), 18 | }, 19 | { 20 | VersionId: 'bad358af-9ec4-490a-9609-c62acd284576', 21 | VersionStages: ['AWSPREVIOUS'], 22 | LastAccessedDate: new Date('2021-05-03T09:00:00+09:00'), 23 | CreatedDate: new Date('2020-12-17T14:47:00.020000+09:00'), 24 | }, 25 | ], 26 | ARN: 'arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:microservice/develop-3zcyRx', 27 | Name: 'microservice/develop', 28 | }, 29 | ) 30 | 31 | const versionId = await awsSecretsManager.getCurrentVersionId('microservice/develop') 32 | expect(versionId).toBe('cf06c560-f2c1-4150-a322-0d2120f7c12e') 33 | }) 34 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/tests/fixtures/expected-with-awssecret-placeholder.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: echoserver 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app.kubernetes.io/name: echoserver 10 | template: 11 | metadata: 12 | labels: 13 | app.kubernetes.io/name: echoserver 14 | spec: 15 | containers: 16 | - image: ${DOCKER_IMAGE} 17 | name: echoserver 18 | envFrom: 19 | - secretRef: 20 | # this should be replaced 21 | name: my-service-c7ea50c5-b2be-4970-bf90-2237bef3b4cf 22 | - image: envoyproxy/envoy 23 | name: envoy 24 | envFrom: 25 | - secretRef: 26 | # this should not be replaced 27 | name: envoy-${AWS_SECRETS_MANAGER_VERSION_ID_ENVOY} 28 | --- 29 | apiVersion: mumoshu.github.io/v1alpha1 30 | kind: AWSSecret 31 | metadata: 32 | name: my-service-c7ea50c5-b2be-4970-bf90-2237bef3b4cf 33 | spec: 34 | stringDataFrom: 35 | secretsManagerSecretRef: 36 | secretId: my-service/develop 37 | versionId: c7ea50c5-b2be-4970-bf90-2237bef3b4cf 38 | --- 39 | apiVersion: v1 40 | kind: Service 41 | metadata: 42 | name: my-service 43 | spec: 44 | ports: 45 | - port: 80 46 | protocol: TCP 47 | targetPort: 3000 48 | selector: 49 | app.kubernetes.io/name: echoserver 50 | --- 51 | apiVersion: mumoshu.github.io/v1alpha1 52 | kind: AWSSecret 53 | metadata: 54 | name: docker-hub 55 | spec: 56 | stringDataFrom: 57 | secretsManagerSecretRef: 58 | secretId: docker-hub-credentials 59 | versionId: 2eb0efcf-14ee-4526-b8ce-971ec82b3aca 60 | type: kubernetes.io/dockerconfigjson 61 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/tests/fixtures/expected-with-externalsecret-placeholder.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: echoserver 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app.kubernetes.io/name: echoserver 10 | template: 11 | metadata: 12 | labels: 13 | app.kubernetes.io/name: echoserver 14 | spec: 15 | containers: 16 | - image: ${DOCKER_IMAGE} 17 | name: echoserver 18 | envFrom: 19 | - secretRef: 20 | # this should be replaced 21 | name: my-service-c7ea50c5-b2be-4970-bf90-2237bef3b4cf 22 | - image: envoyproxy/envoy 23 | name: envoy 24 | envFrom: 25 | - secretRef: 26 | # this should not be replaced 27 | name: envoy-${AWS_SECRETS_MANAGER_VERSION_ID_ENVOY} 28 | --- 29 | apiVersion: external-secrets.io/v1beta1 30 | kind: ExternalSecret 31 | metadata: 32 | name: my-service-c7ea50c5-b2be-4970-bf90-2237bef3b4cf 33 | spec: 34 | secretStoreRef: 35 | name: aws-secrets-manager 36 | kind: ClusterSecretStore 37 | dataFrom: 38 | - extract: 39 | key: my-service/develop 40 | version: uuid/c7ea50c5-b2be-4970-bf90-2237bef3b4cf 41 | --- 42 | apiVersion: v1 43 | kind: Service 44 | metadata: 45 | name: my-service 46 | spec: 47 | ports: 48 | - port: 80 49 | protocol: TCP 50 | targetPort: 3000 51 | selector: 52 | app.kubernetes.io/name: echoserver 53 | --- 54 | apiVersion: external-secrets.io/v1beta1 55 | kind: ExternalSecret 56 | metadata: 57 | name: docker-hub 58 | spec: 59 | target: 60 | template: 61 | type: kubernetes.io/dockerconfigjson 62 | secretStoreRef: 63 | name: aws-secrets-manager 64 | kind: ClusterSecretStore 65 | dataFrom: 66 | - extract: 67 | key: docker-hub-credentials 68 | version: uuid/2eb0efcf-14ee-4526-b8ce-971ec82b3aca 69 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/tests/fixtures/input-with-awssecret-placeholder.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: echoserver 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app.kubernetes.io/name: echoserver 10 | template: 11 | metadata: 12 | labels: 13 | app.kubernetes.io/name: echoserver 14 | spec: 15 | containers: 16 | - image: ${DOCKER_IMAGE} 17 | name: echoserver 18 | envFrom: 19 | - secretRef: 20 | # this should be replaced 21 | name: my-service-${AWS_SECRETS_MANAGER_VERSION_ID} 22 | - image: envoyproxy/envoy 23 | name: envoy 24 | envFrom: 25 | - secretRef: 26 | # this should not be replaced 27 | name: envoy-${AWS_SECRETS_MANAGER_VERSION_ID_ENVOY} 28 | --- 29 | apiVersion: mumoshu.github.io/v1alpha1 30 | kind: AWSSecret 31 | metadata: 32 | name: my-service-${AWS_SECRETS_MANAGER_VERSION_ID} 33 | spec: 34 | stringDataFrom: 35 | secretsManagerSecretRef: 36 | secretId: my-service/develop 37 | versionId: ${AWS_SECRETS_MANAGER_VERSION_ID} 38 | --- 39 | apiVersion: v1 40 | kind: Service 41 | metadata: 42 | name: my-service 43 | spec: 44 | ports: 45 | - port: 80 46 | protocol: TCP 47 | targetPort: 3000 48 | selector: 49 | app.kubernetes.io/name: echoserver 50 | --- 51 | apiVersion: mumoshu.github.io/v1alpha1 52 | kind: AWSSecret 53 | metadata: 54 | name: docker-hub 55 | spec: 56 | stringDataFrom: 57 | secretsManagerSecretRef: 58 | secretId: docker-hub-credentials 59 | versionId: 2eb0efcf-14ee-4526-b8ce-971ec82b3aca 60 | type: kubernetes.io/dockerconfigjson 61 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/tests/fixtures/input-with-externalsecret-placeholder.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: echoserver 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app.kubernetes.io/name: echoserver 10 | template: 11 | metadata: 12 | labels: 13 | app.kubernetes.io/name: echoserver 14 | spec: 15 | containers: 16 | - image: ${DOCKER_IMAGE} 17 | name: echoserver 18 | envFrom: 19 | - secretRef: 20 | # this should be replaced 21 | name: my-service-${AWS_SECRETS_MANAGER_VERSION_ID} 22 | - image: envoyproxy/envoy 23 | name: envoy 24 | envFrom: 25 | - secretRef: 26 | # this should not be replaced 27 | name: envoy-${AWS_SECRETS_MANAGER_VERSION_ID_ENVOY} 28 | --- 29 | apiVersion: external-secrets.io/v1beta1 30 | kind: ExternalSecret 31 | metadata: 32 | name: my-service-${AWS_SECRETS_MANAGER_VERSION_ID} 33 | spec: 34 | secretStoreRef: 35 | name: aws-secrets-manager 36 | kind: ClusterSecretStore 37 | dataFrom: 38 | - extract: 39 | key: my-service/develop 40 | version: uuid/${AWS_SECRETS_MANAGER_VERSION_ID} 41 | --- 42 | apiVersion: v1 43 | kind: Service 44 | metadata: 45 | name: my-service 46 | spec: 47 | ports: 48 | - port: 80 49 | protocol: TCP 50 | targetPort: 3000 51 | selector: 52 | app.kubernetes.io/name: echoserver 53 | --- 54 | apiVersion: external-secrets.io/v1beta1 55 | kind: ExternalSecret 56 | metadata: 57 | name: docker-hub 58 | spec: 59 | target: 60 | template: 61 | type: kubernetes.io/dockerconfigjson 62 | secretStoreRef: 63 | name: aws-secrets-manager 64 | kind: ClusterSecretStore 65 | dataFrom: 66 | - extract: 67 | key: docker-hub-credentials 68 | version: uuid/2eb0efcf-14ee-4526-b8ce-971ec82b3aca 69 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/tests/fixtures/input-with-no-placeholder.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: echoserver 5 | namespace: develop 6 | spec: 7 | replicas: 1 8 | selector: 9 | matchLabels: 10 | app.kubernetes.io/name: echoserver 11 | template: 12 | metadata: 13 | labels: 14 | app.kubernetes.io/name: echoserver 15 | spec: 16 | containers: 17 | - image: envoyproxy/envoy 18 | name: envoy 19 | envFrom: 20 | - secretRef: 21 | name: envoy-${ENVOY_ID} 22 | --- 23 | apiVersion: mumoshu.github.io/v1alpha1 24 | kind: AWSSecret 25 | metadata: 26 | name: docker-hub 27 | namespace: develop 28 | spec: 29 | stringDataFrom: 30 | secretsManagerSecretRef: 31 | secretId: docker-hub-credentials 32 | versionId: 2eb0efcf-14ee-4526-b8ce-971ec82b3aca 33 | type: kubernetes.io/dockerconfigjson 34 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/tests/resolve.test.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs' 2 | import * as os from 'os' 3 | import { replaceSecretVersionIds, updateManifest } from '../src/resolve.js' 4 | 5 | it('replaces the placeholder of AWSSecret with the current version id', async () => { 6 | const manager = { getCurrentVersionId: jest.fn() } 7 | manager.getCurrentVersionId.mockResolvedValue('c7ea50c5-b2be-4970-bf90-2237bef3b4cf') 8 | 9 | const tempdir = await fs.mkdtemp(`${os.tmpdir()}/resolve-aws-secret-version-action-`) 10 | const fixtureFile = `${tempdir}/fixture.yaml` 11 | await fs.copyFile(`${__dirname}/fixtures/input-with-awssecret-placeholder.yaml`, fixtureFile) 12 | 13 | await updateManifest(fixtureFile, manager) 14 | const output = (await fs.readFile(fixtureFile)).toString() 15 | const expected = (await fs.readFile(`${__dirname}/fixtures/expected-with-awssecret-placeholder.yaml`)).toString() 16 | expect(output).toBe(expected) 17 | }) 18 | 19 | it('replaces the placeholder of ExternalSecret with the current version id', async () => { 20 | const manager = { getCurrentVersionId: jest.fn() } 21 | manager.getCurrentVersionId.mockResolvedValue('c7ea50c5-b2be-4970-bf90-2237bef3b4cf') 22 | 23 | const tempdir = await fs.mkdtemp(`${os.tmpdir()}/resolve-aws-secret-version-action-`) 24 | const fixtureFile = `${tempdir}/fixture.yaml` 25 | await fs.copyFile(`${__dirname}/fixtures/input-with-externalsecret-placeholder.yaml`, fixtureFile) 26 | 27 | await updateManifest(fixtureFile, manager) 28 | const output = (await fs.readFile(fixtureFile)).toString() 29 | const expected = (await fs.readFile(`${__dirname}/fixtures/expected-with-externalsecret-placeholder.yaml`)).toString() 30 | expect(output).toBe(expected) 31 | }) 32 | 33 | it('does nothing for an empty string', async () => { 34 | const manager = { getCurrentVersionId: jest.fn() } 35 | const output = await replaceSecretVersionIds('', manager) 36 | expect(output).toBe('') 37 | }) 38 | 39 | it('does nothing for an AWSSecret without a placeholder', async () => { 40 | const manager = { getCurrentVersionId: jest.fn() } 41 | const manifest = `--- 42 | apiVersion: mumoshu.github.io/v1alpha1 43 | kind: AWSSecret 44 | metadata: 45 | name: docker-hub 46 | namespace: \${NAMESPACE} 47 | spec: 48 | stringDataFrom: 49 | secretsManagerSecretRef: 50 | secretId: docker-hub-credentials 51 | versionId: 2eb0efcf-14ee-4526-b8ce-971ec82b3aca 52 | type: kubernetes.io/dockerconfigjson 53 | ` 54 | const output = await replaceSecretVersionIds(manifest, manager) 55 | expect(output).toBe(manifest) 56 | }) 57 | 58 | it('throws an error if invalid AWSSecret', async () => { 59 | const manager = { getCurrentVersionId: jest.fn() } 60 | const manifest = `--- 61 | apiVersion: mumoshu.github.io/v1alpha1 62 | kind: AWSSecret 63 | metadata: 64 | name: docker-hub 65 | spec: 66 | stringDataFrom: 67 | secretsManagerSecretRef: 68 | secretId: this-has-no-versionId-field 69 | ` 70 | await expect(replaceSecretVersionIds(manifest, manager)).rejects.toThrow('AWSSecret must have versionId field') 71 | }) 72 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/tests/run.test.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs' 2 | import * as awsSecretsManager from '../src/awsSecretsManager' 3 | import * as os from 'os' 4 | import { run } from '../src/run.js' 5 | 6 | jest.mock('../src/awsSecretsManager') 7 | const getCurrentVersionId = awsSecretsManager.getCurrentVersionId as jest.Mock 8 | 9 | it('replaces the placeholders of secrets', async () => { 10 | getCurrentVersionId.mockResolvedValue('c7ea50c5-b2be-4970-bf90-2237bef3b4cf') 11 | 12 | const tempdir = await fs.mkdtemp(`${os.tmpdir()}/resolve-aws-secret-version-action-`) 13 | await fs.copyFile( 14 | `${__dirname}/fixtures/input-with-awssecret-placeholder.yaml`, 15 | `${tempdir}/input-with-awssecret-placeholder.yaml`, 16 | ) 17 | await fs.copyFile( 18 | `${__dirname}/fixtures/input-with-externalsecret-placeholder.yaml`, 19 | `${tempdir}/input-with-externalsecret-placeholder.yaml`, 20 | ) 21 | 22 | await run({ 23 | manifests: `${tempdir}/**/*.yaml`, 24 | }) 25 | 26 | expect(await fs.readFile(`${tempdir}/input-with-awssecret-placeholder.yaml`, 'utf-8')).toBe( 27 | await fs.readFile(`${__dirname}/fixtures/expected-with-awssecret-placeholder.yaml`, 'utf-8'), 28 | ) 29 | expect(await fs.readFile(`${tempdir}/input-with-externalsecret-placeholder.yaml`, 'utf-8')).toBe( 30 | await fs.readFile(`${__dirname}/fixtures/expected-with-externalsecret-placeholder.yaml`, 'utf-8'), 31 | ) 32 | }) 33 | -------------------------------------------------------------------------------- /resolve-aws-secret-version/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /substitute/README.md: -------------------------------------------------------------------------------- 1 | # substitute 2 | 3 | This is a general-purpose action to substitute variable(s) in file(s). 4 | 5 | ## Inputs 6 | 7 | | Name | Type | Description | 8 | | ----------- | ---------------- | ---------------------------------- | 9 | | `files` | multiline string | Glob pattern of file(s) | 10 | | `variables` | multiline string | Variable(s) in form of `KEY=VALUE` | 11 | 12 | ## Getting Started 13 | 14 | To build manifests and substitute variables: 15 | 16 | ```yaml 17 | steps: 18 | - uses: int128/kustomize-action@v1 19 | id: kustomize 20 | with: 21 | kustomization: '*/kubernetes/overlays/staging/kustomization.yaml' 22 | - uses: quipper/monorepo-deploy-actions/substitute@v1 23 | with: 24 | files: ${{ steps.kustomize.outputs.directory }} 25 | path-variables-pattern: ${{ steps.kustomize.outputs.directory }}/${service}/** 26 | variables: | 27 | DOCKER_IMAGE=123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/${service}:develop 28 | NAMESPACE=develop 29 | ``` 30 | 31 | If no file is matched, this action does nothing. 32 | -------------------------------------------------------------------------------- /substitute/action.yaml: -------------------------------------------------------------------------------- 1 | name: substitute 2 | description: substitute variable(s) in file(s) 3 | inputs: 4 | files: 5 | description: glob pattern(s) to file(s) in form of multiline string 6 | required: false 7 | variables: 8 | description: variable(s) in form of multiline string with KEY=VALUE 9 | required: true 10 | runs: 11 | using: 'node20' 12 | main: 'dist/index.js' 13 | -------------------------------------------------------------------------------- /substitute/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | clearMocks: true, 5 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /substitute/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "substitute", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "ncc build --source-map --license licenses.txt src/main.ts", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "1.11.1", 12 | "@actions/glob": "0.5.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /substitute/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { parseVariables, run } from '../src/run.js' 3 | 4 | async function main(): Promise { 5 | await run({ 6 | files: core.getInput('files'), 7 | variables: parseVariables(core.getMultilineInput('variables', { required: true })), 8 | }) 9 | } 10 | 11 | main().catch((e: Error) => { 12 | core.setFailed(e) 13 | console.error(e) 14 | }) 15 | -------------------------------------------------------------------------------- /substitute/src/run.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs' 2 | import * as core from '@actions/core' 3 | import * as glob from '@actions/glob' 4 | 5 | type Inputs = { 6 | files: string 7 | variables: Map 8 | } 9 | 10 | export const parseVariables = (variables: string[]): Map => { 11 | const map = new Map() 12 | for (const s of variables) { 13 | const equalIndex = s.indexOf('=') 14 | if (equalIndex === -1) { 15 | throw new Error(`variable must be in the form of key=value: ${s}`) 16 | } 17 | const k = s.substring(0, equalIndex) 18 | const v = s.substring(equalIndex + 1) 19 | map.set(k, v) 20 | } 21 | return map 22 | } 23 | 24 | export const run = async (inputs: Inputs): Promise => { 25 | const files = await glob.create(inputs.files, { matchDirectories: false }) 26 | for await (const f of files.globGenerator()) { 27 | core.info(`reading ${f}`) 28 | const inputManifest = (await fs.readFile(f)).toString() 29 | const outputManifest = replace(inputManifest, inputs.variables) 30 | core.info(`writing to ${f}`) 31 | await fs.writeFile(f, outputManifest, { encoding: 'utf-8' }) 32 | } 33 | } 34 | 35 | const replace = (s: string, variables: Map): string => { 36 | let result = s 37 | for (const [k, v] of variables) { 38 | const placeholder = '${' + k + '}' 39 | core.info(`replace ${placeholder} => ${v}`) 40 | result = replaceAll(result, placeholder, v) 41 | } 42 | return result 43 | } 44 | 45 | const replaceAll = (s: string, oldString: string, newString: string): string => s.split(oldString).join(newString) 46 | -------------------------------------------------------------------------------- /substitute/tests/fixtures/a/generated.yaml: -------------------------------------------------------------------------------- 1 | # fixture 2 | name: a 3 | namespace: ${NAMESPACE} 4 | image: ${DOCKER_IMAGE} 5 | -------------------------------------------------------------------------------- /substitute/tests/fixtures/b/generated.yaml: -------------------------------------------------------------------------------- 1 | # fixture 2 | name: b 3 | namespace: ${NAMESPACE} 4 | image: ${DOCKER_IMAGE} 5 | -------------------------------------------------------------------------------- /substitute/tests/run.test.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os' 2 | import { promises as fs } from 'fs' 3 | import * as path from 'path' 4 | import { parseVariables, run } from '../src/run.js' 5 | 6 | test('variables are replaced', async () => { 7 | const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'substitute-action-')) 8 | 9 | await fs.mkdir(`${workspace}/fixtures`) 10 | await fs.mkdir(`${workspace}/fixtures/a`) 11 | await fs.copyFile(`${__dirname}/fixtures/a/generated.yaml`, `${workspace}/fixtures/a/generated.yaml`) 12 | 13 | await run({ 14 | files: `${workspace}/fixtures/**`, 15 | variables: new Map([ 16 | ['DOCKER_IMAGE', '123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/example:latest'], 17 | ['NAMESPACE', 'develop'], 18 | ]), 19 | }) 20 | 21 | expect(await readContent(`${workspace}/fixtures/a/generated.yaml`)).toBe(`\ 22 | # fixture 23 | name: a 24 | namespace: develop 25 | image: 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/example:latest 26 | `) 27 | }) 28 | 29 | const readContent = async (f: string): Promise => (await fs.readFile(f)).toString() 30 | 31 | describe('parseVariables', () => { 32 | it('parses variables', () => { 33 | expect(parseVariables(['DOCKER_IMAGE=123', 'NAMESPACE=develop', 'VERSION='])).toStrictEqual( 34 | new Map([ 35 | ['DOCKER_IMAGE', '123'], 36 | ['NAMESPACE', 'develop'], 37 | ['VERSION', ''], 38 | ]), 39 | ) 40 | }) 41 | 42 | it('throws an error if variable is not in the form of key=value', () => { 43 | expect(() => parseVariables(['DOCKER_IMAGE=123', 'NAMESPACE'])).toThrow( 44 | 'variable must be in the form of key=value: NAMESPACE', 45 | ) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /substitute/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /template/README.md: -------------------------------------------------------------------------------- 1 | # template [![template](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/template.yaml/badge.svg)](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/template.yaml) 2 | 3 | This is an action to... 4 | 5 | ## Getting Started 6 | 7 | To run this action: 8 | 9 | ```yaml 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: quipper/monorepo-deploy-actions/template@v1 16 | with: 17 | name: hello 18 | ``` 19 | 20 | ## Inputs 21 | 22 | | Name | Default | Description | 23 | | ------ | ---------- | ------------- | 24 | | `name` | (required) | example input | 25 | 26 | ## Outputs 27 | 28 | | Name | Description | 29 | | --------- | -------------- | 30 | | `example` | example output | 31 | -------------------------------------------------------------------------------- /template/action.yaml: -------------------------------------------------------------------------------- 1 | name: template 2 | description: say Hello World 3 | 4 | inputs: 5 | name: 6 | description: example input 7 | required: true 8 | 9 | runs: 10 | using: 'node20' 11 | main: 'dist/index.js' 12 | -------------------------------------------------------------------------------- /template/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | clearMocks: true, 5 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /template/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "template", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "ncc build --source-map --license licenses.txt src/main.ts", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "1.11.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /template/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import { run } from './run.js' 3 | 4 | const main = async (): Promise => { 5 | await run({ 6 | name: core.getInput('name', { required: true }), 7 | }) 8 | } 9 | 10 | main().catch((e: Error) => { 11 | core.setFailed(e) 12 | console.error(e) 13 | }) 14 | -------------------------------------------------------------------------------- /template/src/run.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | interface Inputs { 4 | name: string 5 | } 6 | 7 | // eslint-disable-next-line @typescript-eslint/require-await 8 | export const run = async (inputs: Inputs): Promise => { 9 | core.info(`my name is ${inputs.name}`) 10 | } 11 | -------------------------------------------------------------------------------- /template/tests/run.test.ts: -------------------------------------------------------------------------------- 1 | import { run } from '../src/run.js' 2 | 3 | test('run successfully', async () => { 4 | await expect(run({ name: 'foo' })).resolves.toBeUndefined() 5 | }) 6 | -------------------------------------------------------------------------------- /template/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2023" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /update-outdated-pull-request-branch/README.md: -------------------------------------------------------------------------------- 1 | # update-outdated-pull-request-branch [![update-outdated-pull-request-branch](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/update-outdated-pull-request-branch.yaml/badge.svg)](https://github.com/quipper/monorepo-deploy-actions/actions/workflows/update-outdated-pull-request-branch.yaml) 2 | 3 | This is an action to update the pull request branch if the head commit is outdated. 4 | 5 | ## Problem to solve 6 | 7 | When an outdated pull request is deployed again by `git-push-namespace` action, 8 | 9 | - The generated manifests in the namespace branch is outdated. It may cause some problem. 10 | - A container image may be expired, such as an ECR lifecycle policy. 11 | 12 | It would be nice to automatically update an outdated pull request. 13 | 14 | ## Getting Started 15 | 16 | To update the pull request branch if the head commit is older than 14 days, 17 | 18 | ```yaml 19 | jobs: 20 | update-outdated-pull-request-branch: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: quipper/monorepo-deploy-actions/update-outdated-pull-request-branch@v1 24 | with: 25 | expiration-days: 14 26 | ``` 27 | 28 | This action must be called on `pull_request` event. 29 | 30 | To trigger a workflow against the updated commit, you need to pass a PAT or GitHub App token. 31 | -------------------------------------------------------------------------------- /update-outdated-pull-request-branch/action.yaml: -------------------------------------------------------------------------------- 1 | name: update-outdated-pull-request-branch 2 | description: Update the pull request branch if outdated 3 | 4 | inputs: 5 | expiration-days: 6 | description: Expiration days, must be a positive number 7 | required: true 8 | token: 9 | description: GitHub token 10 | required: true 11 | default: ${{ github.token }} 12 | 13 | runs: 14 | using: 'node20' 15 | main: 'dist/index.js' 16 | -------------------------------------------------------------------------------- /update-outdated-pull-request-branch/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | clearMocks: true, 5 | // https://kulshekhar.github.io/ts-jest/docs/guides/esm-support/ 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /update-outdated-pull-request-branch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "update-outdated-pull-request-branch", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "ncc build --source-map --license licenses.txt src/main.ts", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "1.11.1", 12 | "@actions/github": "6.0.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /update-outdated-pull-request-branch/src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import { run } from './run.js' 4 | import assert from 'assert' 5 | 6 | const main = async (): Promise => { 7 | await run({ 8 | owner: github.context.repo.owner, 9 | repo: github.context.repo.repo, 10 | pullRequestNumber: github.context.issue.number, 11 | pullRequestHeadSHA: getPullRequestHeadSHA(), 12 | expirationDays: Number(core.getInput('expiration-days')), 13 | token: core.getInput('token'), 14 | }) 15 | } 16 | 17 | const getPullRequestHeadSHA = (): string => { 18 | assert(github.context.payload.pull_request, 'This action must be run on pull_request event') 19 | const head: unknown = github.context.payload.pull_request.head 20 | assert(typeof head === 'object') 21 | assert(head !== null) 22 | assert('sha' in head) 23 | assert(typeof head.sha === 'string') 24 | return head.sha 25 | } 26 | 27 | main().catch((e: Error) => { 28 | core.setFailed(e) 29 | console.error(e) 30 | }) 31 | -------------------------------------------------------------------------------- /update-outdated-pull-request-branch/src/run.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import assert from 'assert' 4 | 5 | type Inputs = { 6 | owner: string 7 | repo: string 8 | pullRequestNumber: number 9 | pullRequestHeadSHA: string 10 | expirationDays: number 11 | token: string 12 | } 13 | 14 | export const run = async (inputs: Inputs): Promise => { 15 | assert(inputs.expirationDays > 0, 'expiration-days must be a positive number') 16 | const octokit = github.getOctokit(inputs.token) 17 | 18 | core.info(`Fetching the head commit ${inputs.pullRequestHeadSHA}`) 19 | const { data: headCommit } = await octokit.rest.git.getCommit({ 20 | owner: inputs.owner, 21 | repo: inputs.repo, 22 | commit_sha: inputs.pullRequestHeadSHA, 23 | }) 24 | core.startGroup(`Commit ${inputs.pullRequestHeadSHA}`) 25 | core.info(JSON.stringify(headCommit, undefined, 2)) 26 | core.endGroup() 27 | 28 | core.info(`Last commit was at ${headCommit.committer.date}`) 29 | if (!isExpired(Date.now, headCommit.committer.date, inputs.expirationDays)) { 30 | core.info(`Pull request #${inputs.pullRequestNumber} is not expired, exiting`) 31 | return 32 | } 33 | 34 | core.info(`Updating the pull request branch ${inputs.pullRequestNumber}}`) 35 | await octokit.rest.pulls.updateBranch({ 36 | owner: inputs.owner, 37 | repo: inputs.repo, 38 | pull_number: inputs.pullRequestNumber, 39 | }) 40 | core.info(`Updated the pull request branch ${inputs.pullRequestNumber}}`) 41 | } 42 | 43 | export const isExpired = (now: () => number, headCommitDate: string, expirationDays: number): boolean => { 44 | const headCommitTime = Date.parse(headCommitDate) 45 | return now() - headCommitTime >= expirationDays * 24 * 60 * 60 * 1000 46 | } 47 | -------------------------------------------------------------------------------- /update-outdated-pull-request-branch/tests/run.test.ts: -------------------------------------------------------------------------------- 1 | import { isExpired } from '../src/run.js' 2 | 3 | describe('isExpired', () => { 4 | const now = () => Date.parse('2021-02-03T04:05:06Z') 5 | 6 | it('should return true if expired', () => { 7 | const headCommitDate = '2021-01-31T00:00:00Z' 8 | const expirationDays = 3 9 | expect(isExpired(now, headCommitDate, expirationDays)).toBeTruthy() 10 | }) 11 | 12 | it('should return false if not expired', () => { 13 | const headCommitDate = '2021-02-02T00:00:00Z' 14 | const expirationDays = 3 15 | expect(isExpired(now, headCommitDate, expirationDays)).toBeFalsy() 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /update-outdated-pull-request-branch/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./lib" 5 | } 6 | } 7 | --------------------------------------------------------------------------------