diff --git a/.github/workflows/dogfood.yml b/.github/workflows/dogfood.yml index 0408e0a5b..45da98c94 100644 --- a/.github/workflows/dogfood.yml +++ b/.github/workflows/dogfood.yml @@ -8,25 +8,12 @@ permissions: contents: read packages: write -env: - AWS_REGION: eu-central-1 - AWS_ROLE_ARN: arn:aws:iam::332405224602:role/ci - EARTHLY_VERSION: 0.7.15 - jobs: - dogfood: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: input-output-hk/catalyst-ci/actions/setup@actions-2.0 - with: - aws_role_arn: ${{ env.AWS_ROLE_ARN }} - aws_region: ${{ env.AWS_REGION }} - earthly_version: ${{ env.EARTHLY_VERSION }} - - uses: input-output-hk/catalyst-ci/actions/run@actions-2.0 - with: - earthfile: ./cli - target: build - runner_address: ${{ secrets.EARTHLY_SATELLITE_ADDRESS }} - - name: Test - run: ./cli/bin/ci --help \ No newline at end of file + release: + uses: ./.github/workflows/release.yml + with: + aws_role_arn: arn:aws:iam::332405224602:role/ci + aws_region: eu-central-1 + earthly_version: 0.7.15 + secrets: + earthly_runner_address: ${{ secrets.EARTHLY_SATELLITE_ADDRESS }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..73e866cb0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,133 @@ +on: + workflow_call: + inputs: + target: + description: | + The target used to mark release builds. This target should be unique + across all Earthly files in the repository. The target should always + produce at least one artifact which is included in the final GitHub + release when the workflow is triggered by a tag. + required: false + type: string + default: release + aws_role_arn: + description: | + The ARN of the AWS role that will be assumed by the workflow. Only + required when configuring a remote Earthly runner. + required: false + type: string + aws_region: + description: | + The AWS region that will be used by the workflow. Only required when + configuring a remote Earthly runner. + required: false + type: string + earthly_version: + description: The version of Earthly to use. + required: false + type: string + default: latest + force_artifact: + description: | + When set to true, the workflow will always produce an artifact even + when the current commit is not tagged. + required: false + type: boolean + default: false + secrets: + earthly_runner_address: + description: | + The address of the Earthly runner that will be used to build the + Earthly files. + required: false + +jobs: + discover: + runs-on: ubuntu-latest + outputs: + json: ${{ steps.check.outputs.json }} + steps: + - uses: actions/checkout@v3 + - name: Setup CI + uses: input-output-hk/catalyst-ci/actions/setup@master + with: + aws_role_arn: ${{ inputs.aws_role_arn }} + aws_region: ${{ inputs.aws_region }} + earthly_version: ${{ inputs.earthly_version }} + - name: Discover Earthly files + uses: input-output-hk/catalyst-ci/actions/discover@master + id: discover + with: + targets: ${{ inputs.target }} + - name: Check for empty output + id: check + run: | + output=$(echo '${{ steps.discover.outputs.json }}' | jq -rc) + if [ "$output" == "null" ]; then + echo "json=[]" >> $GITHUB_OUTPUT + else + echo "json=$output" >> $GITHUB_OUTPUT + fi + + build: + runs-on: ubuntu-latest + needs: [discover] + if: needs.discover.outputs.json != '[]' + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + earthfile: ${{ fromJson(needs.discover.outputs.json) }} + steps: + - uses: actions/checkout@v3 + - name: Setup CI + uses: input-output-hk/catalyst-ci/actions/setup@master + with: + aws_role_arn: ${{ inputs.aws_role_arn }} + aws_region: ${{ inputs.aws_region }} + earthly_version: ${{ inputs.earthly_version }} + - name: Build artifact + uses: input-output-hk/catalyst-ci/actions/run@master + id: build + with: + earthfile: ${{ matrix.earthfile }} + target: ${{ inputs.target }} + platform: ${{ matrix.platform }} + runner_address: ${{ secrets.earthly_runner_address }} + artifact: "true" + - name: Generate artifact name + if: startsWith(github.ref, 'refs/tags/') || inputs.force_artifact + id: artifact + run: | + earthfile=$(basename ${{ matrix.earthfile }}) + platform=$(echo '${{ matrix.platform }}' | sed 's/\//-/g') + echo "name=$earthfile-$platform" >> $GITHUB_OUTPUT + - name: Compress artifact + if: startsWith(github.ref, 'refs/tags/') || inputs.force_artifact + run: | + cwd=$(pwd) + cd ${{ steps.build.outputs.artifact }} && tar -czvf "$cwd/${{ steps.artifact.outputs.name }}.tar.gz" . + - name: Upload artifact + uses: actions/upload-artifact@v3 + if: startsWith(github.ref, 'refs/tags/') || inputs.force_artifact + with: + name: ${{ steps.artifact.outputs.name }} + path: ${{ steps.artifact.outputs.name }}.tar.gz + + release: + runs-on: ubuntu-latest + needs: [build] + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + path: artifacts + - name: Collect artifacts + id: collect + run: echo "artifacts=$(find artifacts -type f -name '*.tar.gz')" >> $GITHUB_OUTPUT + - name: Create release + uses: softprops/action-gh-release@v1 + with: + files: ${{ steps.collect.outputs.artifacts }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3b0977879..c9613dfcc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ .direnv .envrc.local +# Earthly +bin/ + # Node node_modules diff --git a/actions/run/action.yml b/actions/run/action.yml index 059c1e582..0069a21ff 100644 --- a/actions/run/action.yml +++ b/actions/run/action.yml @@ -2,14 +2,22 @@ name: "Run" description: "Runs an Earthly target" inputs: artifact: - description: The name of the artifact to export + description: If true, forces artifacts to be saved locally required: false + default: "false" + artifact_path: + description: The path (relative to earthfile) where artifacts will be placed + required: false + default: out earthfile: description: The path to the Earthfile containing the target to run required: true flags: description: Additional flags to pass to the Earthly CLI required: false + platform: + description: The platform to execute the earthfile target with (defaults to native) + required: false runner_address: description: The address of the remote runner to use required: false diff --git a/actions/run/dist/index.js b/actions/run/dist/index.js index f077d8815..0ff958b16 100644 --- a/actions/run/dist/index.js +++ b/actions/run/dist/index.js @@ -2870,29 +2870,37 @@ __nccwpck_require__.r(__webpack_exports__); var core = __nccwpck_require__(186); ;// CONCATENATED MODULE: external "child_process" const external_child_process_namespaceObject = require("child_process"); +// EXTERNAL MODULE: external "path" +var external_path_ = __nccwpck_require__(17); ;// CONCATENATED MODULE: ./src/run.ts + async function run() { - const artifact = core.getInput('artifact'); + const artifact = core.getBooleanInput('artifact'); + const artifactPath = core.getInput('artifact_path'); const earthfile = core.getInput('earthfile'); const flags = core.getInput('flags'); + const platform = core.getInput('platform'); const runnerAddress = core.getInput('runner_address'); const runnerPort = core.getInput('runner_port'); const target = core.getInput('target'); const targetFlags = core.getInput('target_flags'); const command = 'earthly'; const args = []; - if (artifact) { - args.push('--artifact', `${earthfile}+${target}/${artifact}`, `${artifact}`); - } if (runnerAddress) { args.push('--buildkit-host', `tcp://${runnerAddress}:${runnerPort}`); } + if (platform) { + args.push('--platform', platform); + } if (flags) { args.push(...flags.split(' ')); } - if (!artifact) { + if (artifact) { + args.push('--artifact', `${earthfile}+${target}/`, `${artifactPath}`); + } + else { args.push(`${earthfile}+${target}`); } if (targetFlags) { @@ -2900,22 +2908,32 @@ async function run() { } core.info(`Running command: ${command} ${args.join(' ')}`); const output = await spawnCommand(command, args); - // TODO: The newest version of Earthly attaches annotations to the images - let matches; - const imageRegex = /^Image .*? output as (.*?)$/gm; - const images = []; - while ((matches = imageRegex.exec(output)) !== null) { - images.push(matches[1]); - } - const artifactRegex = /^Artifact .*? output as (.*?)$/gm; - const artifacts = []; - while ((matches = artifactRegex.exec(output)) !== null) { - artifacts.push(matches[1]); - } - core.info(`Found images: ${images.join(' ')}`); - core.info(`Found artifacts: ${artifacts.join(' ')}`); - core.setOutput('images', images.join(' ')); - core.setOutput('artifacts', artifacts.join(' ')); + const imageOutput = parseImage(output); + if (imageOutput) { + core.info(`Found image: ${imageOutput}`); + core.setOutput('image', imageOutput); + } + const artifactOutput = external_path_.join(earthfile, parseArtifact(output)); + if (artifactOutput !== earthfile) { + core.info(`Found artifact: ${artifactOutput}`); + core.setOutput('artifact', artifactOutput); + } +} +function parseArtifact(output) { + const regex = /^Artifact .*? output as (.*?)$/gm; + const match = regex.exec(output); + if (match) { + return match[1]; + } + return ''; +} +function parseImage(output) { + const regex = /^Image .*? output as (.*?)$/gm; + const match = regex.exec(output); + if (match) { + return match[1]; + } + return ''; } async function spawnCommand(command, args) { return new Promise((resolve, reject) => { diff --git a/actions/run/src/run.test.ts b/actions/run/src/run.test.ts index 097bc0286..2f2cb6c79 100644 --- a/actions/run/src/run.test.ts +++ b/actions/run/src/run.test.ts @@ -3,6 +3,7 @@ import { spawn, SpawnOptionsWithoutStdio } from 'child_process' import { run } from './run' jest.mock('@actions/core', () => ({ + getBooleanInput: jest.fn(), getInput: jest.fn(), info: jest.fn(), setOutput: jest.fn() @@ -22,34 +23,40 @@ describe('Run Action', () => { it.each([ { artifact: '', - earthfile: 'earthfile', + artifactPath: '', + earthfile: './earthfile', flags: '', + platform: '', output: '', runnerAddress: '', runnerPort: '', target: 'target', targetFlags: '--flag1 test -f2 test2', - command: ['earthfile+target', '--flag1', 'test', '-f2', 'test2'], - images: '', - artifacts: '' + command: ['./earthfile+target', '--flag1', 'test', '-f2', 'test2'], + imageOutput: '', + artifactOutput: '' }, { - artifact: 'artifact', - earthfile: 'earthfile', - flags: '', - output: 'Artifact +target/artifact output as artifact\n', + artifact: 'true', + artifactPath: 'out', + earthfile: './earthfile', + flags: '--test', + platform: '', + output: 'Artifact +target/artifact output as out\n', runnerAddress: '', runnerPort: '', target: 'target', targetFlags: '', - command: ['--artifact', 'earthfile+target/artifact', 'artifact'], - images: '', - artifacts: 'artifact' + command: ['--test', '--artifact', './earthfile+target/', 'out'], + imageOutput: '', + artifactOutput: 'earthfile/out' }, { artifact: '', - earthfile: 'earthfile', + artifactPath: '', + earthfile: './earthfile', flags: '', + platform: '', output: '', runnerAddress: 'localhost', runnerPort: '8372', @@ -58,49 +65,63 @@ describe('Run Action', () => { command: [ '--buildkit-host', 'tcp://localhost:8372', - 'earthfile+target' + './earthfile+target' ], - images: '', - artifacts: '' + imageOutput: '', + artifactOutput: '' }, { artifact: '', - earthfile: 'earthfile', + artifactPath: '', + earthfile: './earthfile', flags: '--flag1 test -f2 test2', - output: - 'Image +docker output as image1:tag1\nImage +docker output as image2:tag2\n', + platform: 'linux/amd64', + output: 'Image +docker output as image1:tag1\n', runnerAddress: '', runnerPort: '', target: 'target', targetFlags: '', - command: ['--flag1', 'test', '-f2', 'test2', 'earthfile+target'], - images: 'image1:tag1 image2:tag2', - artifacts: '' + command: [ + '--platform', + 'linux/amd64', + '--flag1', + 'test', + '-f2', + 'test2', + './earthfile+target' + ], + imageOutput: 'image1:tag1', + artifactOutput: '' } ])( `should execute the correct command`, async ({ artifact, + artifactPath, earthfile, flags, + platform, output, runnerAddress, runnerPort, target, targetFlags, command, - images, - artifacts + imageOutput, + artifactOutput }) => { const getInputMock = core.getInput as jest.Mock + const getBooleanInputMock = core.getBooleanInput as jest.Mock getInputMock.mockImplementation((name: string) => { switch (name) { - case 'artifact': - return artifact + case 'artifact_path': + return artifactPath case 'earthfile': return earthfile case 'flags': return flags + case 'platform': + return platform case 'output': return output case 'runner_address': @@ -116,6 +137,15 @@ describe('Run Action', () => { } }) + getBooleanInputMock.mockImplementation((name: string) => { + switch (name) { + case 'artifact': + return artifact === 'true' + default: + throw new Error('Unknown input') + } + }) + const spawnMock = spawn as jest.Mock spawnMock.mockImplementation(createSpawnMock('stdout', output, 0)) @@ -125,8 +155,19 @@ describe('Run Action', () => { expect(spawn).toHaveBeenCalledWith('earthly', command) expect(stdoutSpy).toHaveBeenCalledWith('stdout') expect(stderrSpy).toHaveBeenCalledWith(output) - expect(core.setOutput).toHaveBeenCalledWith('images', images) - expect(core.setOutput).toHaveBeenCalledWith('artifacts', artifacts) + + if (imageOutput) { + // eslint-disable-next-line jest/no-conditional-expect + expect(core.setOutput).toHaveBeenCalledWith('image', imageOutput) + } + + if (artifact === 'true') { + // eslint-disable-next-line jest/no-conditional-expect + expect(core.setOutput).toHaveBeenCalledWith( + 'artifact', + artifactOutput + ) + } } ) }) diff --git a/actions/run/src/run.ts b/actions/run/src/run.ts index 4d6aa0aaf..22f2cd13b 100644 --- a/actions/run/src/run.ts +++ b/actions/run/src/run.ts @@ -1,10 +1,13 @@ import * as core from '@actions/core' import { spawn } from 'child_process' +import * as path from 'path' export async function run(): Promise { - const artifact = core.getInput('artifact') + const artifact = core.getBooleanInput('artifact') + const artifactPath = core.getInput('artifact_path') const earthfile = core.getInput('earthfile') const flags = core.getInput('flags') + const platform = core.getInput('platform') const runnerAddress = core.getInput('runner_address') const runnerPort = core.getInput('runner_port') const target = core.getInput('target') @@ -13,19 +16,21 @@ export async function run(): Promise { const command = 'earthly' const args: string[] = [] - if (artifact) { - args.push('--artifact', `${earthfile}+${target}/${artifact}`, `${artifact}`) - } - if (runnerAddress) { args.push('--buildkit-host', `tcp://${runnerAddress}:${runnerPort}`) } + if (platform) { + args.push('--platform', platform) + } + if (flags) { args.push(...flags.split(' ')) } - if (!artifact) { + if (artifact) { + args.push('--artifact', `${earthfile}+${target}/`, `${artifactPath}`) + } else { args.push(`${earthfile}+${target}`) } @@ -36,25 +41,37 @@ export async function run(): Promise { core.info(`Running command: ${command} ${args.join(' ')}`) const output = await spawnCommand(command, args) - // TODO: The newest version of Earthly attaches annotations to the images - let matches - const imageRegex = /^Image .*? output as (.*?)$/gm - const images = [] - while ((matches = imageRegex.exec(output)) !== null) { - images.push(matches[1]) + const imageOutput = parseImage(output) + if (imageOutput) { + core.info(`Found image: ${imageOutput}`) + core.setOutput('image', imageOutput) } - const artifactRegex = /^Artifact .*? output as (.*?)$/gm - const artifacts = [] - while ((matches = artifactRegex.exec(output)) !== null) { - artifacts.push(matches[1]) + const artifactOutput = path.join(earthfile, parseArtifact(output)) + if (artifactOutput !== earthfile) { + core.info(`Found artifact: ${artifactOutput}`) + core.setOutput('artifact', artifactOutput) + } +} + +function parseArtifact(output: string): string { + const regex = /^Artifact .*? output as (.*?)$/gm + const match = regex.exec(output) + if (match) { + return match[1] } - core.info(`Found images: ${images.join(' ')}`) - core.info(`Found artifacts: ${artifacts.join(' ')}`) + return '' +} + +function parseImage(output: string): string { + const regex = /^Image .*? output as (.*?)$/gm + const match = regex.exec(output) + if (match) { + return match[1] + } - core.setOutput('images', images.join(' ')) - core.setOutput('artifacts', artifacts.join(' ')) + return '' } async function spawnCommand(command: string, args: string[]): Promise { diff --git a/cli/Earthfile b/cli/Earthfile index 724b2f662..e01dd0417 100644 --- a/cli/Earthfile +++ b/cli/Earthfile @@ -43,8 +43,10 @@ build: ENV CGO_ENABLED=0 RUN go build -ldflags="-extldflags=-static" -o bin/ci cmd/main.go +release: + FROM +build + SAVE ARTIFACT bin/ci ci - SAVE ARTIFACT bin/ci AS LOCAL bin/ci docker: FROM earthly/earthly:v0.7.6 diff --git a/cli/bin/ci b/cli/bin/ci deleted file mode 100755 index d979fb3d5..000000000 Binary files a/cli/bin/ci and /dev/null differ diff --git a/cli/cmd/main.go b/cli/cmd/main.go index 8c3ae5ebd..50d68c303 100644 --- a/cli/cmd/main.go +++ b/cli/cmd/main.go @@ -140,10 +140,11 @@ func (c *scanCmd) Run() error { } if c.JSONOutput { - var paths []string + paths := make([]string, 0) for _, file := range files { paths = append(paths, filepath.Dir(file.Path)) } + jsonFiles, err := json.Marshal(paths) if err != nil { return err