diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c7face8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +--- +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every week + interval: "weekly" diff --git a/CHANGELOG.md b/CHANGELOG.md index 597ef27..4f32a45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,12 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Action for automatic Docker image build and push - Custom tag option - Add `context` argument to allow for Dockerfiles in subfolders +- Delete docker versions when git branches/tags are deleted ### Changed - Unpack `build-release` folder - Replace `jbutcher5/read-yaml` with `mikefarah/yq` for YAML parsing - Use `${github.token}` as default value for `github-token`. +- Require usage of , change `registry` input to `organization` +- Build on pushes to all branches +- Tag non-`main` branches as `branch-` diff --git a/README.md b/README.md index 55dbcb5..fb79fcf 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,83 @@ -# Project/Repo Title +# Docker Build Action -Template Repository for the Boutros Lab general project repos. Describe a simple overview of use/purpose here. +An Action to automatically build and push images to the [GitHub Container registry](https://github.com/features/packages). ## Description -An in-depth paragraph about your project and overview of use. +This action will build and push images of the form `ghcr.io//:`. The `` field is controlled by the following logic: -## License +| Pushed Ref | Type | Resulting Tag | +| ---------- | -------------- | ----------------- | +| `main` | default branch | `dev` | +| `mybranch` | branch | `branch-mybranch` | +| `v1.2.3` | tag | `1.2.3` | + +When a git branch or tag is deleted, the corresponding docker will be deleted as well. + +## Usage + +```yaml +--- +name: Update image in GHCR + +run-name: > + ${{ + github.event_name == 'delete' && format( + 'Delete `{0}{1}`', + github.event.ref_type == 'branch' && 'branch-' || '', + github.event.ref + ) + || github.ref == 'refs/heads/main' && 'Update `dev`' + || format( + 'Update `{0}{1}`', + !startsWith(github.ref, 'refs/tags') && 'branch-' || '', + github.ref_name + ) + }} docker tag + +on: + push: + branches-ignore: ['gh-pages'] + tags: ['v*'] + delete: -Author: Name1(username1@mednet.ucla.edu), Name2(username2@mednet.ucla.edu) +jobs: + push-or-delete-image: + runs-on: ubuntu-latest + name: Update GitHub Container Registry + permissions: + contents: read + packages: write + steps: + - uses: uclahs-cds/tool-Docker-action@v2 +``` + +The complicated `run-name` logic above controls the workflow run names listed on the Actions page: + +| Ref Name | Ref Type | `push` Run Name | `delete` Run Name | +| -------------------- | -------- | ----------------------------------- | ----------------------------------- | +| Push to `main` | branch | Update `dev` docker tag | Delete `dev` docker tag | +| Push to `mybranch` | branch | Update `branch-mybranch` docker tag | Delete `branch-mybranch` docker tag | +| Push to `v1.2.3` tag | tag | Update `v1.2.3` docker tag | Delete `v1.2.3` docker tag | + +### Inputs + +| Name | Default | Description | +| ---- | ------- | ----------- | +| `organization` | -- | The GitHub organizational host of the image. Defaults to the organization of the calling repository. | +| `metadata-file` | `metadata.yaml` | Metadata file storing the image name. | +| `image-name-key-path` | `.image_name` | [`yq`](https://github.com/mikefarah/yq) query for the image name within the metadata file. | +| `github-token` | `github.token` | Token used for authentication. Requires `contents: read` for the calling repository and `packages:write` for the host organization. | +| `custom-tags` | -- | Additional lines to add to the [docker/metadata-action `tags` argument](https://github.com/docker/metadata-action?tab=readme-ov-file#tags-input). | +| `context` | `.` | The docker build context. Only required if the `Dockerfile` is not in the repository root. | + +## License -[This project] is licensed under the GNU General Public License version 2. See the file LICENSE.md for the terms of the GNU GPL license. +Author: Nicholas Wiltsie (nwiltsie@mednet.ucla.edu), Yash Patel (yashpatel@mednet.ucla.edu) - +tool-docker-action is licensed under the GNU General Public License version 2. See the file LICENSE.md for the terms of the GNU GPL license. -Copyright (C) 2021 University of California Los Angeles ("Boutros Lab") All rights reserved. +Copyright (C) 2024 University of California Los Angeles ("Boutros Lab") All rights reserved. This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. diff --git a/action.yml b/action.yml index 6815c48..29712c5 100644 --- a/action.yml +++ b/action.yml @@ -1,10 +1,9 @@ --- name: 'Docker-build-release' -description: 'Build Docker image and push to repository' +description: 'Build Docker image and push to GHCR' inputs: - registry: - description: 'Registry to which image will be pushed' - default: ghcr.io/uclahs-cds + organization: + description: 'Organizational host for the image. Defaults to the organization of the calling repository.' metadata-file: description: 'Metadata YAML file containing information' default: metadata.yaml @@ -31,32 +30,70 @@ runs: - name: Read YAML id: yaml-data - uses: mikefarah/yq@v4 + uses: mikefarah/yq@v4.44.2 with: cmd: yq '${{ inputs.image-name-key-path }}' '${{ inputs.metadata-file }}' + - name: Parse organization + id: parse-org + shell: bash + env: + CALLING_ORGANIZATION: ${{ github.event.organization.login }} + INPUT_ORGANIZATION: ${{ inputs.organization }} + run: echo "org=${INPUT_ORGANIZATION:-$CALLING_ORGANIZATION}" >> "$GITHUB_OUTPUT" + + # Take this path if the event is a branch deletion + - if: github.event_name == 'delete' + name: Delete matching docker tags + uses: actions/github-script@v7 + env: + ORGANIZATION: ${{ steps.parse-org.outputs.org }} + IMAGE_NAME: ${{ steps.yaml-data.outputs.result }} + with: + script: | + const script = require(`${process.env['GITHUB_ACTION_PATH']}/delete-tags.js`) + await script({ github, context, core }) + + # Take this path if the event is not a deletion - name: Create tags + if: github.event_name != 'delete' id: meta uses: docker/metadata-action@v5 with: flavor: | latest=false - images: ${{ inputs.registry }}/${{ steps.yaml-data.outputs.result }} + images: ghcr.io/${{ steps.parse-org.outputs.org }}/${{ steps.yaml-data.outputs.result }} tags: | - type=raw,enable=${{github.event_name == 'push'}},value=dev,event=branch - type=match,pattern=v(.*),group=1 + type=raw,enable=${{ github.ref == 'refs/heads/main' }},value=dev + type=ref,enable=${{ github.ref != 'refs/heads/main' }},prefix=branch-,event=branch + type=semver,pattern={{version}} ${{ inputs.custom-tags }} - name: Log in to the Container registry + if: github.event_name != 'delete' uses: docker/login-action@v3 with: - registry: ${{ inputs.registry }} + registry: ghcr.io/${{ steps.parse-org.outputs.org }} username: ${{ github.actor }} password: ${{ inputs.github-token }} - name: Build and push Docker image + id: buildpush + if: github.event_name != 'delete' uses: docker/build-push-action@v5 with: context: ${{ inputs.context }} push: true tags: ${{ steps.meta.outputs.tags }} + + - if: github.event_name != 'delete' + name: Log comment with image URL + uses: actions/github-script@v7 + env: + ORGANIZATION: ${{ steps.parse-org.outputs.org }} + IMAGE_NAME: ${{ steps.yaml-data.outputs.result }} + IMAGE_DIGEST: ${{ steps.buildpush.outputs.digest }} + with: + script: | + const script = require(`${process.env['GITHUB_ACTION_PATH']}/post-url.js`) + await script({ github, context, core }) diff --git a/delete-tags.js b/delete-tags.js new file mode 100644 index 0000000..faed84c --- /dev/null +++ b/delete-tags.js @@ -0,0 +1,49 @@ +module.exports = async ({ github, context, core }) => { + const { IMAGE_NAME, ORGANIZATION } = process.env + + let tagName + + if (context.payload.ref_type === 'branch') { + tagName = `branch-${context.payload.ref}` + } else { + tagName = context.payload.ref.match(/^v(.*)$/)[1] + } + + let didDelete = false + + for await (const response of github.paginate.iterator( + github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, { + package_type: 'container', + package_name: IMAGE_NAME, + org: ORGANIZATION + })) { + for (const version of response.data) { + const tags = version.metadata?.container?.tags + if (tags?.includes(tagName)) { + core.notice(`Package version ${version.html_url} matches tag ${tagName} and will be deleted`) + + const otherTags = tags.filter((tag) => tag !== tagName) + if (otherTags.length) { + core.warning(`Image version has other tags that will be lost: ${otherTags}`) + } + + await github.rest.packages.deletePackageVersionForOrg({ + package_type: 'container', + package_name: IMAGE_NAME, + org: ORGANIZATION, + package_version_id: version.id + }) + + didDelete = true + break + } + } + if (didDelete) { + break + } + } + + if (!didDelete) { + core.warning(`Did not find version tagged ${tagName}`) + } +} diff --git a/metadata.yaml b/metadata.yaml index eb9a6dd..35e4f15 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,8 +1,13 @@ --- -Category: '' # shoule be one of docker/pipeline/project/template/tool/training/users -Description: '' # Description of why the repository exists -Maintainers: ['someone@mednet.ucla.edu', 'someoneelse@mednet.ucla.edu'] # email address of maintainers -Contributors: 'Xavier Hernandez' # Full names of contributors -Languages: ['R', 'perl', 'nextflow'] # programming languages used -Dependencies: 'BPG' # packages, tools that repo needs to run -References: '' # is the tool/dependencies published, is there a confluence page +Category: tool +Description: GitHub Action to build and deploy docker images +Maintainers: + - nwiltsie@mednet.ucla.edu +Contributors: + - Nicholas Wiltsie + - Yash Patel +Languages: + - bash + - javascript +Dependencies: +References: diff --git a/post-url.js b/post-url.js new file mode 100644 index 0000000..ab2952e --- /dev/null +++ b/post-url.js @@ -0,0 +1,19 @@ +module.exports = async ({ github, context, core }) => { + const { ORGANIZATION, IMAGE_NAME, IMAGE_DIGEST } = process.env + + for await (const response of github.paginate.iterator( + github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, { + package_type: 'container', + package_name: IMAGE_NAME, + org: ORGANIZATION + })) { + for (const version of response.data) { + if (version.name === IMAGE_DIGEST) { + core.notice(`Uploaded new image ${version.html_url}`) + return + } + } + } + + core.error('Could not find URL for new image!') +}