Skip to content

Commit

Permalink
Support GitHub Deployment (environment-matrix)
Browse files Browse the repository at this point in the history
  • Loading branch information
int128 committed Oct 23, 2023
1 parent a07ef96 commit 6c5a4af
Show file tree
Hide file tree
Showing 12 changed files with 237 additions and 40 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/environment-matrix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,15 @@ jobs:
environments:
- overlay: pr
namespace: pr-${{ github.event.pull_request.number }}
github-deployment: 'true'
github-deployment-environment: pr/pr-${{ github.event.pull_request.number }}/example
- push:
ref: refs/heads/main
environments:
- overlay: development
namespace: development
github-deployment: 'true'
github-deployment-environment: development/development/example
e2e-test-matrix:
needs: e2e-test
Expand All @@ -75,3 +79,4 @@ jobs:
steps:
- run: echo 'overlay=${{ matrix.environment.overlay }}'
- run: echo 'namespace=${{ matrix.environment.namespace }}'
- run: echo 'github-deployment-url=${{ matrix.environment.github-deployment-url }}'
37 changes: 32 additions & 5 deletions environment-matrix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ For example, when `main` branch is pushed, this action returns the following JSO
This action finds a rule in order.
If no rule is matched, this action fails.

## GitHub Deployment

This action supports [GitHub Deployment](https://docs.github.com/en/rest/deployments/deployments) to receive the deployment status against the environment.

To create a GitHub Deployment for each environment,

- Set `github-deployment` field to `true`
- Set `github-deployment-environment` field to the environment name

If the old deployment exists, this action deletes it and recreates new one.

This action sets `github-deployment-url` field to the output.
See the example in the next section.

### Workflow example

Here is the workflow of matrix jobs.
Expand All @@ -57,11 +71,15 @@ jobs:
environments:
- overlay: pr
namespace: pr-${{ github.event.pull_request.number }}
github-deployment: 'true'
github-deployment-environment: pr/pr-${{ github.event.pull_request.number }}/example
- push:
ref: refs/heads/main
environments:
- overlay: development
namespace: development
github-deployment: 'true'
github-deployment-environment: development/development/example
deploy:
needs:
Expand All @@ -79,26 +97,35 @@ jobs:
overlay: ${{ matrix.environment.overlay }}
namespace: ${{ matrix.environment.namespace }}
service: # (omit in this example)
application-annotations: |
argocd-commenter.int128.github.io/deployment-url=${{ matrix.github-deployment-url }}
```

## Spec

### Inputs

| Name | Type | Description |
| ------- | -------- | -------------------- |
| `rules` | `string` | YAML string of rules |
| Name | Default | Description |
| ------- | -------------- | -------------------- |
| `rules` | (required) | YAML string of rules |
| `token` | `github.token` | GitHub token |

The following fields are available in the rules YAML.

```yaml
- pull_request: # on pull_request event
base: # base branch name (wildcard available)
head: # head branch name (wildcard available)
environments: # array of map<string, string>
environments:
- # array of map<string, string>
github-deployment: 'true' # set true to create a GitHub Deployment (optional)
github-deployment-environment: # environment name of GitHub Deployment (optional)
- push: # on push event
ref: refs/heads/main # ref name (wildcard available)
environments: # array of map<string, string>
environments:
- # array of map<string, string>
github-deployment: 'true' # set true to create a GitHub Deployment (optional)
github-deployment-environment: # environment name of GitHub Deployment (optional)
```

It supports the wildcard pattern.
Expand Down
4 changes: 4 additions & 0 deletions environment-matrix/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ inputs:
rules:
description: YAML string of rules
required: true
token:
description: GitHub token
required: true
default: ${{ github.token }}
outputs:
json:
description: JSON string of environments
Expand Down
1 change: 1 addition & 0 deletions environment-matrix/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"dependencies": {
"@actions/core": "1.10.1",
"@actions/github": "6.0.0",
"@octokit/plugin-retry": "6.0.1",
"ajv": "8.12.0",
"js-yaml": "4.1.0",
"minimatch": "9.0.3"
Expand Down
73 changes: 73 additions & 0 deletions environment-matrix/src/deployment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import { Environment } from './rule'
import { RequestError } from '@octokit/request-error'
import { Octokit, assertPullRequestPayload } from './github'
import assert from 'assert'

type Context = Pick<typeof github.context, 'eventName' | 'repo' | 'ref' | 'payload'>

export const createGitHubDeploymentForEnvironments = async (
octokit: Octokit,
context: Context,
environments: Environment[],
) => {
for (const environment of environments) {
if (environment['github-deployment'] === 'true' && environment['github-deployment-environment']) {
const deploymentEnvironment = environment['github-deployment-environment']
const deployment = await createDeployment(octokit, context, deploymentEnvironment)
environment['github-deployment-url'] = deployment.url
}
}
}

const createDeployment = async (octokit: Octokit, context: Context, deploymentEnvironment: string) => {
core.info(`Finding the old deployments for environment ${deploymentEnvironment}`)
const oldDeployments = await octokit.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: deploymentEnvironment,
})

core.info(`Deleting ${oldDeployments.data.length} deployment(s)`)
for (const deployment of oldDeployments.data) {
try {
await octokit.rest.repos.deleteDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
})
core.info(`Deleted the old deployment ${deployment.url}`)
} catch (error) {
if (error instanceof RequestError) {
core.warning(`Unable to delete previous deployment ${deployment.url}: ${error.status} ${error.message}`)
continue
}
throw error
}
}

const deploymentRef = getDeploymentRef(context)
core.info(`Creating a deployment for environment=${deploymentEnvironment}, ref=${deploymentRef}`)
const created = await octokit.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: deploymentRef,
environment: deploymentEnvironment,
auto_merge: false,
required_contexts: [],
transient_environment: context.eventName === 'pull_request',
})
assert.strictEqual(created.status, 201)
core.info(`Created a deployment ${created.data.url}`)
return created.data
}

const getDeploymentRef = (context: Context): string => {
if (context.eventName === 'pull_request') {
// Set the head ref to associate a deployment with the pull request
assertPullRequestPayload(context.payload.pull_request)
return context.payload.pull_request.head.ref
}
return context.ref
}
37 changes: 37 additions & 0 deletions environment-matrix/src/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from 'assert'
import * as pluginRetry from '@octokit/plugin-retry'
import { GitHub, getOctokitOptions } from '@actions/github/lib/utils'

export type Octokit = InstanceType<typeof GitHub>

export const getOctokit = (token: string): Octokit => {
const MyOctokit = GitHub.plugin(pluginRetry.retry)
return new MyOctokit(getOctokitOptions(token, { previews: ['ant-man', 'flash'] }))
}

// picked from https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
export type PullRequestPayload = {
head: {
ref: string
}
base: {
ref: string
}
}

export function assertPullRequestPayload(x: unknown): asserts x is PullRequestPayload {
assert(typeof x === 'object')
assert(x != null)

assert('base' in x)
assert(typeof x.base === 'object')
assert(x.base != null)
assert('ref' in x.base)
assert(typeof x.base.ref === 'string')

assert('head' in x)
assert(typeof x.head === 'object')
assert(x.head != null)
assert('ref' in x.head)
assert(typeof x.head.ref === 'string')
}
1 change: 1 addition & 0 deletions environment-matrix/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { run } from './run'
const main = async (): Promise<void> => {
const outputs = await run({
rules: core.getInput('rules', { required: true }),
token: core.getInput('token', { required: true }),
})
core.setOutput('json', outputs.environments)
}
Expand Down
36 changes: 4 additions & 32 deletions environment-matrix/src/matcher.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'assert'
import * as github from '@actions/github'
import { minimatch } from 'minimatch'
import { Environment, Rule, Rules } from './rule'
import { assertPullRequestPayload } from './github'

type Context = Pick<typeof github.context, 'eventName' | 'ref' | 'payload'>

Expand All @@ -15,42 +15,14 @@ export const find = (context: Context, rules: Rules): Environment[] | undefined

const match = (context: Context, rule: Rule): boolean => {
if (context.eventName === 'pull_request' && rule.pull_request !== undefined) {
const { pull_request } = context.payload
assertPullRequestPayload(pull_request)
assertPullRequestPayload(context.payload.pull_request)
return (
minimatch(pull_request.base.ref, rule.pull_request.base) &&
minimatch(pull_request.head.ref, rule.pull_request.head)
minimatch(context.payload.pull_request.base.ref, rule.pull_request.base) &&
minimatch(context.payload.pull_request.head.ref, rule.pull_request.head)
)
}
if (context.eventName === 'push' && rule.push !== undefined) {
return minimatch(context.ref, rule.push.ref)
}
return false
}

// picked from https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
type PullRequestPayload = {
head: {
ref: string
}
base: {
ref: string
}
}

function assertPullRequestPayload(x: unknown): asserts x is PullRequestPayload {
assert(typeof x === 'object')
assert(x != null)

assert('base' in x)
assert(typeof x.base === 'object')
assert(x.base != null)
assert('ref' in x.base)
assert(typeof x.base.ref === 'string')

assert('head' in x)
assert(typeof x.head === 'object')
assert(x.head != null)
assert('ref' in x.head)
assert(typeof x.head.ref === 'string')
}
9 changes: 8 additions & 1 deletion environment-matrix/src/rule.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import * as yaml from 'js-yaml'
import Ajv, { JTDSchemaType } from 'ajv/dist/jtd'

export type Environment = Record<string, string>
export type Environment = Record<string, string> & {
// (input) If set to true, create a GitHub Deployment
'github-deployment'?: string
// (input) The environment name of GitHub Deployment
'github-deployment-environment'?: string
// (output) The URL of GitHub Deployment, set by this action
'github-deployment-url'?: string
}

const EnvironmentSchema: JTDSchemaType<Environment> = {
values: {
Expand Down
11 changes: 9 additions & 2 deletions environment-matrix/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,31 @@ import * as core from '@actions/core'
import * as github from '@actions/github'
import { Environment, parseRulesYAML } from './rule'
import { find } from './matcher'
import { createGitHubDeploymentForEnvironments } from './deployment'
import { getOctokit } from './github'

type Inputs = {
rules: string
token: string
}

type Outputs = {
environments: Environment[]
}

// eslint-disable-next-line @typescript-eslint/require-await
export const run = async (inputs: Inputs): Promise<Outputs> => {
const rules = parseRulesYAML(inputs.rules)
core.info(`rules: ${JSON.stringify(rules, undefined, 2)}`)
const environments = find(github.context, rules)
if (environments === undefined) {
throw new Error(`no environment to deploy`)
}
core.info(`environments: ${JSON.stringify(environments, undefined, 2)}`)
core.info(`environments = ${JSON.stringify(environments, undefined, 2)}`)

core.info(`Creating GitHub Deployments if needed`)
const octokit = getOctokit(inputs.token)
await createGitHubDeploymentForEnvironments(octokit, github.context, environments)
core.info(`environments = ${JSON.stringify(environments, undefined, 2)}`)
return {
environments,
}
Expand Down
49 changes: 49 additions & 0 deletions environment-matrix/tests/rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,55 @@ test('parse a valid YAML', () => {
])
})

test('parse rules with GitHub Deployment', () => {
const yaml = `
- pull_request:
base: '**'
head: '**'
environments:
- overlay: pr
namespace: pr-1
github-deployment: 'true'
github-deployment-environment: pr/pr-1
- push:
ref: refs/heads/main
environments:
- overlay: development
namespace: development
github-deployment: 'true'
github-deployment-environment: development/development
`
expect(parseRulesYAML(yaml)).toStrictEqual<Rules>([
{
pull_request: {
base: '**',
head: '**',
},
environments: [
{
overlay: 'pr',
namespace: 'pr-1',
'github-deployment': 'true',
'github-deployment-environment': 'pr/pr-1',
},
],
},
{
push: {
ref: 'refs/heads/main',
},
environments: [
{
overlay: 'development',
namespace: 'development',
'github-deployment': 'true',
'github-deployment-environment': 'development/development',
},
],
},
])
})

test('parse an empty string', () => {
expect(() => parseRulesYAML('')).toThrow(`invalid rules YAML: must be array`)
})
Expand Down
Loading

0 comments on commit 6c5a4af

Please sign in to comment.