├── .editorconfig ├── LICENSE ├── README.md ├── SECURITY.md └── action.yml /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_style = tab 12 | 13 | [*.yml] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 John Blackbourn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WordPress Plugin Attestation 2 | 3 | Do you use GitHub Actions to deploy your plugin to the wordpress.org plugin directory? Add this action to your deployment workflow to generate a [build provenance attestation](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) of the plugin zip file on wordpress.org. 4 | 5 | This action integrates well with [the WordPress Plugin Deploy action](https://github.com/marketplace/actions/wordpress-plugin-deploy), but it can work with any workflow which deploys your plugin. 6 | 7 | ## What is this and why should I use it? 8 | 9 |
10 |13 | 14 | This action generates an artifact attestation for the zip file that is served by the plugin directory for each release of your plugin. This can subsequently be used by consumers to verify that a given version of your plugin actually originated from your user account on GitHub. 15 | 16 | There is not much tooling for the verification aspect at the moment — other than the `gh attestation verify` command — but this ultimately facilitates verifying that a plugin release came from its trusted author rather than an unwanted entity, for example somebody who stole your SVN password, hacked into wordpress.org, or performed a hostile plugin takeover. 17 | 18 | ## Usage 19 | 20 | Within the GitHub Actions workflow which deploys your plugin to the plugin directory: 21 | 22 | 1. Ensure that at least the following permissions are set: 23 | 24 | ```yaml 25 | permissions: 26 | id-token: write 27 | attestations: write 28 | ``` 29 | 30 | 2. Add the following step to your workflow so it runs after your plugin has been deployed: 31 | 32 | ```yaml 33 | - uses: johnbillion/action-wordpress-plugin-attestation@0.7.1 34 | with: 35 | zip-path: my-plugin-slug.zip 36 | ``` 37 | 38 | ## Example workflow 39 | 40 | ```yaml 41 | jobs: 42 | deploy: 43 | name: Deploy 44 | runs-on: ubuntu-latest 45 | permissions: 46 | attestations: write 47 | contents: read 48 | id-token: write 49 | timeout-minutes: 60 50 | steps: 51 | - name: Deploy to the plugin directory 52 | uses: 10up/action-wordpress-plugin-deploy@v2 53 | id: deploy 54 | env: 55 | SVN_USERNAME: ${{ secrets.WPORG_SVN_USERNAME }} 56 | SVN_PASSWORD: ${{ secrets.WPORG_SVN_PASSWORD }} 57 | with: 58 | generate-zip: true 59 | - name: Generate build provenance attestation 60 | uses: johnbillion/action-wordpress-plugin-attestation@0.7.1 61 | with: 62 | zip-path: ${{ steps.deploy.outputs.zip-path }} 63 | ``` 64 | 65 | ## Inputs 66 | 67 | Here is the full list of required and optional inputs: 68 | 69 | ```yaml 70 | - uses: johnbillion/action-wordpress-plugin-attestation@0.7.1 71 | with: 72 | # Required. Path to the zip file generated for the plugin release. 73 | # Use `${{ steps.deploy.outputs.zip-path }}` if you're using the 74 | # "WordPress.org Plugin Deploy" action. 75 | zip-path: my-plugin-slug.zip 76 | 77 | # Optional. Plugin slug name. Default is the repo name. 78 | plugin: my-plugin-slug 79 | 80 | # Optional. Plugin version number. Default is the tag name if 81 | # triggered by pushing a tag or creating a release. 82 | version: 1.2.3 83 | 84 | # Optional. Maximum time in minutes to spend trying to fetch the 85 | # zip from the plugin directory. Default is 60. 86 | timeout: 60 87 | 88 | # Optional. Whether to perform a dry run which runs everything 89 | # except for generating the actual attestation. Default false. 90 | dry-run: false 91 | 92 | # Optional. The URL where the plugin zip file is hosted (for 93 | # platforms other than wordpress.org). Default is the URL of 94 | # the zip file on the wordpress.org plugin directory. 95 | zip-url: 'https://example.com/%plugin%-%version%.zip' 96 | ``` 97 | 98 | ## Outputs 99 | 100 | | Name | Description | Example | 101 | | ----------------- | -------------------------------------------------------------- | ------------------------------------------------------ | 102 | | `attestation-id` | GitHub ID for the attestation | `123456` | 103 | | `attestation-url` | URL for the attestation summary | `https://github.com/foo/bar/attestations/123456` | 104 | | `bundle-path` | Absolute path to the file containing the generated attestation | `/tmp/attestation.json` | 105 | | `zip-url` | URL where the plugin zip file is hosted | `https://downloads.wordpress.org/plugin/foo.1.2.3.zip` | 106 | 107 | ## Can't I just use `actions/attest-build-provenance`? 108 | 109 | This action is a wrapper for the `actions/attest-build-provenance` action provided by GitHub. It specifically handles generating an attestation for the zip file of your plugin once it's been deployed to the plugin directory. This facilitates consumers being able to verify the provenance of the zip file that they download from wordpress.org, not just for an artifact on GitHub. 110 | 111 | ## Does this work if my plugin has a build step? 112 | 113 | Yes, this action supports plugins that have a build step because it is only concerned about whatever you commit to the plugin directory. Just call this action with a zip of those files and you're good to go. 114 | 115 | ## Does this work if release confirmation is enabled? 116 | 117 | Yes, this action specifically supports [plugin release confirmation](https://developer.wordpress.org/plugins/wordpress-org/release-confirmation-emails/). It will periodically attempt to fetch the plugin zip from the plugin directory for up to 60 minutes, which allows you plenty of time to confirm the release. 118 | 119 | ## Does this work for hosts other than wordpress.org? 120 | 121 | Yes, this action supports hosts other than wordpress.org in case you want to generate an attestation for a zip file that you deploy elsewhere. The `zip-url` input can be used to specify a custom zip URL to fetch and attest. These dynamic value placeholders can be used within the URL: 122 | 123 | * `%plugin%` for the plugin slug 124 | * `%version%` for the version number 125 | 126 | The default zip URL is `https://downloads.wordpress.org/plugin/%plugin%.%version%.zip`. 127 | 128 | If you deploy your plugin to multiple locations, call this action once for each. 129 | 130 | ## How do I verify a plugin that publishes attestations? 131 | 132 | At a minimum you need to know the name of the owner of the repo that the plugin was built from, for example `johnbillion`. 133 | 134 | Then you can fetch the plugin zip file at a specific version and verify its provenance using the `gh` command: 135 | 136 | ### Verify provenance using the owner name 137 | 138 | The `--owner` option works regardless of whether or not the plugin uses a reusable workflow for its deployment: 139 | 140 | ```sh 141 | wget https://downloads.wordpress.org/plugin/query-monitor.3.16.4.zip 142 | gh attestation verify query-monitor.3.16.4.zip \ 143 | --owner johnbillion 144 | ``` 145 | 146 | ### Verify provenance using the repo name 147 | 148 | The `--repo` option only works only if the plugin is not using a reusable workflow for its deployment: 149 | 150 | ```sh 151 | wget https://downloads.wordpress.org/plugin/query-monitor.3.16.4.zip 152 | gh attestation verify query-monitor.3.16.4.zip \ 153 | --repo johnbillion/query-monitor 154 | ``` 155 | 156 | ### Verify provenance using the repo name and signer repo name 157 | 158 | The combined `--repo` and `--signer-repo` options work if the plugin uses a reusable workflow for its deployment: 159 | 160 | ```sh 161 | wget https://downloads.wordpress.org/plugin/query-monitor.3.16.4.zip 162 | gh attestation verify query-monitor.3.16.4.zip \ 163 | --repo johnbillion/query-monitor \ 164 | --signer-repo johnbillion/plugin-infrastructure 165 | ``` 166 | 167 | ## How can I test this action without doing a release? 168 | 169 | Create a `workflow_dispatch` workflow that calls the `johnbillion/action-wordpress-plugin-attestation` action with the zip file of your plugin. You can then run this workflow against a branch or tag of your choice from the Actions screen of your repo. 170 | 171 | Optionally use the `dry-run` parameter to perform all the verification steps without publishing the attestation. 172 | 173 |Artifact attestations enable you to increase the supply chain security of your builds by establishing where and how your software was built.
11 | 12 |
The time that I spend maintaining this library and others is in part sponsored by:
228 | 229 | 230 | 231 | 232 | 233 |Plus all my kind sponsors on GitHub:
234 | 235 | 236 | 237 |Click here to find out about supporting my open source tools and plugins.
238 | 239 | ## License 240 | 241 | MIT 242 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Issues 2 | 3 | To report a security issue with this action, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/johnbillion/action-wordpress-plugin-attestation/security/advisories/new) tab. 4 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: WordPress Plugin Attestation 2 | description: Generates an attestation for the build provenance of a plugin published on the wordpress.org plugin directory 3 | author: johnbillion 4 | branding: 5 | icon: check-circle 6 | color: green 7 | 8 | inputs: 9 | zip-path: 10 | description: The local path to the plugin zip file. 11 | required: true 12 | type: string 13 | plugin: 14 | description: Optional. The plugin slug. 15 | required: false 16 | type: string 17 | default: ${{ github.event.repository.name }} 18 | version: 19 | description: Optional. The version number of the release. 20 | required: false 21 | type: string 22 | default: ${{ github.ref_name }} 23 | timeout: 24 | description: Optional. The maximum time in minutes to wait for the plugin zip to become available in the plugin directory. 25 | required: false 26 | type: number 27 | default: 60 28 | zip-url: 29 | description: Optional. The URL where the plugin zip file is hosted (for platforms other than wordpress.org). 30 | required: false 31 | type: string 32 | default: 'https://downloads.wordpress.org/plugin/%plugin%.%version%.zip' 33 | dry-run: 34 | description: Optional. Set this to true to skip generating the actual attestation. 35 | required: false 36 | type: boolean 37 | default: false 38 | outputs: 39 | bundle-path: 40 | description: Absolute path to the file containing the generated attestation. 41 | value: ${{ steps.generate-attestation.outputs.bundle-path }} 42 | attestation-id: 43 | description: GitHub ID for the attestation. 44 | value: ${{ steps.generate-attestation.outputs.attestation-id }} 45 | attestation-url: 46 | description: URL for the attestation summary. 47 | value: ${{ steps.generate-attestation.outputs.attestation-url }} 48 | zip-url: 49 | description: URL where the plugin zip file is hosted. 50 | value: ${{ steps.fetch-zip.outputs.zip-url }} 51 | 52 | runs: 53 | using: composite 54 | steps: 55 | # This fetches the zipped plugin from the plugin directory. The zip might not exist yet if the plugin uses release confirmation 56 | # and the release hasn't been confirmed. This will retry until the zip is available or the timeout is reached. 57 | - name: Fetch zip from the plugin directory 58 | id: fetch-zip 59 | env: 60 | PLUGIN: ${{ inputs.plugin }} 61 | VERSION: ${{ inputs.version }} 62 | ZIP_URL: ${{ inputs.zip-url }} 63 | TIMEOUT: ${{ inputs.timeout }} 64 | run: | #shell 65 | zipurl="$ZIP_URL" 66 | zipurl=${zipurl//%plugin%/$PLUGIN} 67 | zipurl=${zipurl//%version%/$VERSION} 68 | 69 | echo PLUGIN_HOST="$(echo "$zipurl" | awk -F/ '{print $3}')" >> "$GITHUB_ENV" 70 | echo zip-url="$zipurl" >> "$GITHUB_OUTPUT" 71 | 72 | echo "Fetching plugin zip from $zipurl ..." 73 | elapsed=0 74 | sleep=10 75 | per_minute=$((60 / $sleep)) 76 | max_retries=$(( ${TIMEOUT} * $per_minute - 1 )) 77 | 78 | while [ $elapsed -lt $max_retries ]; do 79 | # Perform a HEAD request to check if the zip is available 80 | status_code=$(curl --silent --output /dev/null --write-out "%{http_code}" --head "$zipurl") 81 | if [ "$status_code" -eq 200 ]; then 82 | curl --silent --output "${PLUGIN}.zip" "$zipurl" 83 | break 84 | else 85 | echo "Plugin zip not available yet (HTTP status $status_code), retrying in $sleep seconds..." 86 | sleep $sleep 87 | elapsed=$((elapsed + 1)) 88 | fi 89 | done 90 | 91 | if [ $elapsed -ge $max_retries ]; then 92 | echo "Error: ${TIMEOUT} minute timeout reached. Plugin zip not available." 93 | exit 1 94 | fi 95 | shell: bash 96 | 97 | # Now compare the contents of the generated zip and the plugin directory zip to ensure they match. 98 | # Only then should an attestation for the plugin directory zip be generated. 99 | - name: Unzip the zip from the plugin directory 100 | env: 101 | PLUGIN: ${{ inputs.plugin }} 102 | run: | #shell 103 | unzip -q -d zip-deployed "${PLUGIN}.zip" 104 | shell: bash 105 | 106 | - name: Unzip the generated zip 107 | env: 108 | ZIP_PATH: ${{ inputs.zip-path }} 109 | run: | #shell 110 | unzip -q -d zip-generated "${ZIP_PATH}" 111 | shell: bash 112 | 113 | - name: Ensure the contents are identical 114 | run: | #shell 115 | diff --recursive zip-generated zip-deployed 116 | shell: bash 117 | 118 | - name: Generate attestation for the zip 119 | if: ${{ inputs.dry-run == 'false' }} 120 | id: generate-attestation 121 | uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 122 | env: 123 | PLUGIN: ${{ inputs.plugin }} 124 | VERSION: ${{ inputs.version }} 125 | with: 126 | subject-path: "${{ github.workspace }}/${{ env.PLUGIN }}.zip" 127 | subject-name: "${{ env.PLUGIN_HOST }}-${{ env.PLUGIN }}-${{ env.VERSION }}" 128 | 129 | - name: Verify the attestation 130 | if: ${{ inputs.dry-run == 'false' }} 131 | env: 132 | GH_TOKEN: ${{ github.token }} 133 | PLUGIN: ${{ inputs.plugin }} 134 | run: | #shell 135 | gh attestation verify "${PLUGIN}.zip" --owner "${GITHUB_REPOSITORY_OWNER}" 136 | shell: bash 137 | --------------------------------------------------------------------------------