diff --git a/.github/workflows/test-post-artifact.yml b/.github/workflows/test-post-artifact.yml new file mode 100644 index 0000000..bf9b081 --- /dev/null +++ b/.github/workflows/test-post-artifact.yml @@ -0,0 +1,76 @@ +name: Test PR post artifact + +on: + pull_request: + branches: [main] + pull_request_target: + paths: + - '.github/workflows/test-post-artifact.yml' + - 'post-artifact/action.yml' + push: + branches: [main] + paths: + - '.github/workflows/test-post-artifact.yml' + - 'post-artifact/action.yml' + +jobs: + build-artifact-container: + runs-on: ubuntu-latest + container: rocker/tidyverse:4.4.0 + + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v4 + name: Checkout code + + - uses: actions/upload-artifact@v4 + name: Upload artifact + with: + path: './README.md' + name: 'readme-rocker' + + - name: Install gh + run: | + apt update + apt install -y --no-install-recommends gh + + - name: Post the artifact + if: ${{ github.event_name == 'pull_request' }} + uses: ./post-artifact + with: + artifact-name: 'readme-rocker' + python: 'python3' + gh-token: ${{ secrets.GITHUB_TOKEN }} + + build-artifact: + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + runs-on: ${{ matrix.os }} + + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v4 + name: Checkout code + + - uses: actions/upload-artifact@v4 + name: Upload artifact + with: + path: './README.md' + name: ${{ format('readme-{0}', matrix.os) }} + + - name: Post the artifact + if: ${{ github.event_name == 'pull_request' }} + uses: ./post-artifact + with: + artifact-name: ${{ format('readme-{0}', matrix.os) }} + gh-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index a3cae32..e6bf3ff 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,17 @@ -# CDCgov GitHub Organization Open Source Project Template +## CFA Actions -**Template for clearance: This project serves as a template to aid projects in starting up and moving through clearance procedures. To start, create a new repository and implement the required [open practices](open_practices.md), train on and agree to adhere to the organization's [rules of behavior](rules_of_behavior.md), and [send a request through the create repo form](https://forms.office.com/Pages/ResponsePage.aspx?id=aQjnnNtg_USr6NJ2cHf8j44WSiOI6uNOvdWse4I-C2NUNk43NzMwODJTRzA4NFpCUk1RRU83RTFNVi4u) using language from this template as a Guide.** +> [!CAUTION] +> This product is still in development. +> Note the product is still flagged as in development, though the authors plan on using it for production work in the coming weeks. +> All development is in public as part of the Center for Forecasting and Outbreak Analytics' goals around open development. +> Questions and suggestions are welcome through GitHub issues or a PR. +> -**General disclaimer** This repository was created for use by CDC programs to collaborate on public health related projects in support of the [CDC mission](https://www.cdc.gov/about/organization/mission.htm). GitHub is not hosted by the CDC, but is a third party website used by CDC and its partners to share information and collaborate on software. CDC use of GitHub does not imply an endorsement of any one particular service, product, or enterprise. +This repo contains personalized actions designed by CDC's CFA team. Please use with caution as these actions are not officially supported by GitHub. -## Access Request, Repo Creation Request - -* [CDC GitHub Open Project Request Form](https://forms.office.com/Pages/ResponsePage.aspx?id=aQjnnNtg_USr6NJ2cHf8j44WSiOI6uNOvdWse4I-C2NUNk43NzMwODJTRzA4NFpCUk1RRU83RTFNVi4u) _[Requires a CDC Office365 login, if you do not have a CDC Office365 please ask a friend who does to submit the request on your behalf. If you're looking for access to the CDCEnt private organization, please use the [GitHub Enterprise Cloud Access Request form](https://forms.office.com/Pages/ResponsePage.aspx?id=aQjnnNtg_USr6NJ2cHf8j44WSiOI6uNOvdWse4I-C2NUQjVJVDlKS1c0SlhQSUxLNVBaOEZCNUczVS4u).]_ - -## Related documents - -* [Open Practices](open_practices.md) -* [Rules of Behavior](rules_of_behavior.md) -* [Thanks and Acknowledgements](thanks.md) -* [Disclaimer](DISCLAIMER.md) -* [Contribution Notice](CONTRIBUTING.md) -* [Code of Conduct](code-of-conduct.md) - -## Overview - -Describe the purpose of your project. Add additional sections as necessary to help collaborators and potential collaborators understand and use your project. +- [post-artifact](./post-artifact): Post an artifact as a comment in a PR. Useful when you need to easily access a built element during a workflow such as a website, a report, etc. + ## Public Domain Standard Notice This repository constitutes a work of the United States Government and is not subject to domestic copyright protection under 17 USC ยง 105. This repository is in @@ -30,6 +21,7 @@ All contributions to this repository will be released under the CC0 dedication. submitting a pull request you are agreeing to comply with this waiver of copyright interest. + ## License Standard Notice The repository utilizes code licensed under the terms of the Apache Software License and therefore is licensed under ASL v2 or later. @@ -73,3 +65,6 @@ published through the [CDC web site](http://www.cdc.gov). ## Additional Standard Notices Please refer to [CDC's Template Repository](https://github.com/CDCgov/template) for more information about [contributing to this repository](https://github.com/CDCgov/template/blob/main/CONTRIBUTING.md), [public domain notices and disclaimers](https://github.com/CDCgov/template/blob/main/DISCLAIMER.md), and [code of conduct](https://github.com/CDCgov/template/blob/main/code-of-conduct.md). + +> [!IMPORTANT] +**General disclaimer** This repository was created for use by CDC programs to collaborate on public health related projects in support of the [CDC mission](https://www.cdc.gov/about/organization/mission.htm). GitHub is not hosted by the CDC, but is a third party website used by CDC and its partners to share information and collaborate on software. CDC use of GitHub does not imply an endorsement of any one particular service, product, or enterprise. \ No newline at end of file diff --git a/post-artifact/README.md b/post-artifact/README.md new file mode 100644 index 0000000..02f3ff2 --- /dev/null +++ b/post-artifact/README.md @@ -0,0 +1,95 @@ +# Post artifact as a comment [![Test PR post artifact](https://github.com/CDCgov/cfa-actions/actions/workflows/test-post-artifact.yml/badge.svg)](https://github.com/CDCgov/cfa-actions/actions/workflows/test-post-artifact.yml) + +## Inputs + +| Field | Description | Required | Default | +|---------------|-----------------------------------------------------------------------------------------------------------------------------------------|----------|----------------| +| `gh-token` | The GitHub token to use for the API calls. | true | - | +| `artifact-name` | Artifact name as in the `actions/upload-artifact` step. | false | `artifact` | +| `message` | Message template to be posted in the PR, as Github-flavored Markdown (GFM). Use the placeholder `{ artifact-url }` to reference the URL of the uploaded artifact, either raw or as GFM formatted link. Use the `{ artifact-name }` placeholder to include the artifact name in the message | false | `'Thank you for your contribution ${{ github.actor }} :rocket:! Your { artifact-name } is ready for download :point_right: [here]({ artifact-url }) :point_left:!'` | +| `python` | The path to the Python executable. | false | `'python'` | + +This action only runs in PRs and requires the `pull-requests: write` permission. + +## Example: Post artifact created within a job + +Here are the contents of a job that (i) uploads an artifact using `actions/upload-artifact` and (ii) posts the artifact as a comment using this action. The action requires the runner to have: `python` and `gh cli` installed. + + +```yaml + # Required permissions + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v4 + name: Checkout code + + # Uploading an artifact with name 'readme' + - uses: actions/upload-artifact@v4 + name: Upload artifact + with: + path: './README.md' + name: readme + + # Post the artifact pulling the id from the `readme` step. + - name: Post the artifact + uses: CDCgov/cfa-actions/post-artifact@main + if: ${{ github.event_name == 'pull_request' }} + with: + artifact-name: readme + gh-token: ${{ secrets.GITHUB_TOKEN }} + message: 'Thank you for your contribution ${{ github.actor }} :rocket:! Your { artifact-name } is ready for download :point_right: [here]({ artifact-url }) :point_left:!' +``` + +For a live example, see [../.github/workflows/test-post-artifact.yml](../.github/workflows/test-post-artifact.yml). + +## Example: Post an artifact created in a previous job + +When passing between jobs, the artifact id can be passed as an output. Here is an example of how to do it: + +```yaml +jobs: + + build: + + runs-on: ubuntu-latest + + # Expose the artifact id as an output + outputs: + artifact-id: ${{ steps.upload.outputs.artifact-id }} + + steps: + - uses: actions/checkout@v4 + name: Checkout code + + # Uploading an artifact with id 'readme' + - uses: actions/upload-artifact@v4 + name: Upload artifact + with: + path: './README.md' + name: readme + + post: + runs-on: ubuntu-latest + + # This job depends on the `build` job + needs: build + + # Required permissions + permissions: + contents: read + pull-requests: write + + steps: + # Post the artifact pulling the id from the `readme` step. + # The msg will refer to the arfitact as 'README file'. + - name: Post the artifact + if: ${{ github.event_name == 'pull_request' }} + uses: CDCgov/cfa-actions/post-artifact@main + with: + artifact-name: readme + gh-token: ${{ secrets.GITHUB_TOKEN }} + +``` diff --git a/post-artifact/action.yml b/post-artifact/action.yml new file mode 100644 index 0000000..d5d3499 --- /dev/null +++ b/post-artifact/action.yml @@ -0,0 +1,130 @@ +name: post-artifact +description: | + Posts a comment on a PR linking to an artifact uploaded in CI for easy access and reference. + Subsequent runs will update the comment with an updated link to the artifact. +inputs: + artifact-name: + description: | + Name of the artifact to which to link (should match the one passed to + `actions/upload-artifact`). + required: false + default: 'artifact' + message: + description: | + Message template to be posted in the PR, as Github-flavored + Markdown (GFM). Use the placeholder `{ artifact-url }` to + reference the URL of the uploaded artifact, either raw + or as GFM formatted link. Use the `{ artifact-name }` + placeholder to include the artifact name in the message. + required: false + default: 'Thank you for your contribution @${{ github.actor }} :rocket:! Your { artifact-name } is ready for download :point_right: [here]({ artifact-url }) :point_left:!' + python: + description: | + The path to the Python executable. This input is optional and + defaults to 'python'. + required: false + default: 'python' + gh-token: + description: | + The GitHub token to use for the API calls. + required: true +runs: + using: 'composite' + + steps: + + - name: Check this is a PR + if: ${{ github.event_name != 'pull_request' }} + run: + echo "This action must only run in pull requests." + echo "See https://github.com/CDCgov/cfa-actions for more information." + exit 1 + shell: bash + + # https://docs.github.com/en/rest/actions/artifacts?apiVersion=2022-11-28#list-workflow-run-artifacts + - name: "Get list of available artifacts and store as .json" + run: | + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts > _artifacts-${{ github.sha }}.json + shell: bash + env: + GH_TOKEN: ${{ inputs.gh-token }} + + - name: Fetch metadata for the target artifact + run: ${{ inputs.python }} ${GITHUB_ACTION_PATH}/scripts/fetch-artifact-meta.py + shell: bash + env: + ARTIFACT_NAME: ${{ inputs.artifact-name }} + SHA: ${{ github.sha }} + + - name: Get artifact id + id: artifact-meta + run: | + echo "id=$(cat '${{ github.sha }}_artifact_id')" >> $GITHUB_OUTPUT + echo "expires_at=$(cat '${{ github.sha }}_artifact_expires_at')" >> $GITHUB_OUTPUT + shell: bash + + - name: Compose message + run: ${{ inputs.python }} ${GITHUB_ACTION_PATH}/scripts/compose-msg.py + shell: bash + env: + ARTIFACT_NAME: ${{ inputs.artifact-name }} + MESSAGE: ${{ inputs.message }} + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + ARTIFACT_ID: ${{ steps.artifact-meta.outputs.id }} + SHA: ${{ github.sha }} + EXP_DATE: ${{ steps.artifact-meta.outputs.expires_at }} + + - name: Get the event + run: | + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + repos/${{ github.repository }}/issues/${{ github.event.number }}/comments > _events-${{ github.sha }}.json + shell: bash + env: + GH_TOKEN: ${{ inputs.gh-token }} + + - name: Find the comment + run: ${{ inputs.python }} ${GITHUB_ACTION_PATH}/scripts/find-comment.py + shell: bash + env: + SHA: ${{ github.sha }} + ARTIFACT_NAME: ${{ inputs.artifact-name }} + + - name: Putting the contents of _msg.txt into an environment var + id: set-env + run: | + echo "ID=$(cat _ID-${{ github.sha }})" >> $GITHUB_OUTPUT + echo "FOUND=$(cat _ID-${{ github.sha }}_found)" >> $GITHUB_OUTPUT + shell: bash + + # See: + # https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#update-an-issue-comment + - name: Add comment + if: ${{ steps.set-env.outputs.FOUND == 'false' }} + run: | + echo "No comment from github-bot found, adding a new one." + gh pr comment -R ${{ github.repository }} \ + ${{ github.event.number }} -F msg-${{ github.sha }}.txt + shell: bash + env: + GH_TOKEN: ${{ inputs.gh-token }} + + - name: Update comment + if: ${{ steps.set-env.outputs.FOUND == 'true' }} + run: | + echo "Editing original comment id: ${{ steps.set-env.outputs.ID }}." + gh api \ + --method PATCH \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + repos/${{ github.repository }}/issues/comments/${{ steps.set-env.outputs.ID }} \ + -F 'body=@msg-${{ github.sha }}.txt' + shell: bash + env: + GH_TOKEN: ${{ inputs.gh-token }} diff --git a/post-artifact/scripts/compose-msg.py b/post-artifact/scripts/compose-msg.py new file mode 100644 index 0000000..1b15dc1 --- /dev/null +++ b/post-artifact/scripts/compose-msg.py @@ -0,0 +1,44 @@ +import re + +# Retrieving environment variables +import os +import sys + +ARTIFACT_NAME = os.environ.get('ARTIFACT_NAME') +MESSAGE = os.environ.get('MESSAGE') +SERVER_URL = os.environ.get('SERVER_URL') +REPOSITORY = os.environ.get('REPOSITORY') +RUN_ID = os.environ.get('RUN_ID') +ARTIFACT_ID = os.environ.get('ARTIFACT_ID') +SHA = os.environ.get('SHA') +EXP_DATE = os.environ.get('EXP_DATE') + +msg = "\n" +msg = msg + MESSAGE + +updated = re.sub( + r'{ artifact-name }', + ARTIFACT_NAME, + msg + ) + +if not re.search(r'{ artifact-url }', updated): + print( + "The message template must include the placeholder " \ + "{ artifact-url }." + ) + sys.exit(1) + +updated = re.sub( + r'{ artifact-url }', + SERVER_URL + '/' + REPOSITORY + '/actions/runs/' + RUN_ID + \ + '/artifacts/' + ARTIFACT_ID, + updated + ) + +run_url = f"{SERVER_URL}/{REPOSITORY}/actions/runs/{RUN_ID}" +updated = updated + \ + f'\n(The artifact expires on {EXP_DATE}. You can re-generate it by re-running the workflow [here]({run_url}).)' + +with open('msg-' + SHA + '.txt', 'w') as file: + file.write(updated) \ No newline at end of file diff --git a/post-artifact/scripts/fetch-artifact-meta.py b/post-artifact/scripts/fetch-artifact-meta.py new file mode 100644 index 0000000..ecddadd --- /dev/null +++ b/post-artifact/scripts/fetch-artifact-meta.py @@ -0,0 +1,45 @@ +import re +import json +import sys +import os + +# Retrieving environment variables +ARTIFACT_NAME = os.environ.get('ARTIFACT_NAME') +SHA = os.environ.get('SHA') + +def find_artifact() -> dict: + fn = '_artifacts-' + SHA + '.json' + with open(fn, 'r') as file: + data = json.load(file) + + artifacts = data.get('artifacts') + + for i in range(len(artifacts)): + + print(f"Artifact: {artifacts[i]}") + + name = artifacts[i].get('name') + id = artifacts[i].get('id') + expires_at = artifacts[i].get('expires_at') + + if not name: + continue + + if not id: + continue + + if ARTIFACT_NAME == name: + return dict(id=str(id), expires_at=expires_at) + return '' + +meta = find_artifact() + +if meta.get('id') == '': + print(f"Artifact { ARTIFACT_NAME } not found.") + sys.exit(1) + +with open(SHA + '_artifact_id', 'w') as file: + file.write(meta.get('id')) + +with open(SHA + '_artifact_expires_at', 'w') as file: + file.write(meta.get('expires_at')) \ No newline at end of file diff --git a/post-artifact/scripts/find-comment.py b/post-artifact/scripts/find-comment.py new file mode 100644 index 0000000..a9e8680 --- /dev/null +++ b/post-artifact/scripts/find-comment.py @@ -0,0 +1,62 @@ +import json +import re +import os + +# Retrieving environment variables +ARTIFACT_NAME = os.environ.get('ARTIFACT_NAME') +SHA = os.environ.get('SHA') + +def main(json_comments) -> str: + + # Open the JSON file and load its contents into a Python object + with open(json_comments, 'r') as file: + data = json.load(file) + + if (data == []): + return '' + + matching_msg = re.escape( + "" + ) + + # Now you can work with the 'data' object + for i in range(len(data)): + + body = data[i].get('body') + auth = data[i].get('user').get('login') + url = data[i].get('url') + + if not url: + continue + + if not auth: + continue + + if not body: + continue + + match = re.search(r'\d+$', url) + + if not match: + continue + + id = match.group() + + # Regex match to the body of the comment looking + # for the expression "Thank you for your contribution" + # if found, print the author and the body of the comment + if (re.search(matching_msg, body)) and (re.match(r'^github-actions\[bot\]', auth)): + return id + + return '' + +id = main('_events-' + SHA + '.json') +fn = '_ID-' + SHA +with (open(fn, 'w')) as file: + file.write(id) + +with (open(fn+'_found', 'w')) as file: + if id == '': + file.write('false') + else: + file.write('true') \ No newline at end of file