diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..f10d8577 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +# Builds the release source package in CI when a draft release is created +# (typically via support/release.py), uploads the zip to that release, and +# attaches a SLSA v1.0 provenance attestation generated by the OpenSSF +# slsa-github-generator. The maintainer reviews the draft (which by then has +# both the zip and *.intoto.jsonl attached) and clicks Publish to finalize. +# +# This makes the provenance attest to the actual build that produced the +# artifact, rather than just attesting to a hash observed after the fact. + +name: release + +on: + release: + types: [created] + +permissions: read-all + +jobs: + build: + name: Build source package + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + hashes: ${{ steps.hash.outputs.hashes }} + package: ${{ steps.build.outputs.package }} + steps: + - name: Checkout the release ref + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + ref: ${{ github.event.release.target_commitish }} + persist-credentials: false + + - name: Build source zip via CPack + id: build + run: | + cmake -B build . + cmake --build build --target package_source + pkg=$(ls build/fmt-*.zip) + test -f "$pkg" + echo "package=$pkg" >> "$GITHUB_OUTPUT" + + - name: Compute base64-encoded SHA-256 subjects + id: hash + run: | + file="${{ steps.build.outputs.package }}" + subjects=$(cd "$(dirname "$file")" && sha256sum "$(basename "$file")") + echo "hashes=$(printf '%s' "$subjects" | base64 -w0)" >> "$GITHUB_OUTPUT" + + - name: Upload zip to the release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ github.event.release.tag_name }}" \ + "${{ steps.build.outputs.package }}" \ + --repo "${{ github.repository }}" --clobber + + provenance: + needs: [build] + permissions: + actions: read + id-token: write + contents: write + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 + with: + base64-subjects: ${{ needs.build.outputs.hashes }} + provenance-name: "fmt-${{ github.event.release.tag_name }}.intoto.jsonl" + upload-assets: true + upload-tag-name: ${{ github.event.release.tag_name }} diff --git a/support/release.py b/support/release.py index 26de7f4f..caabf431 100755 --- a/support/release.py +++ b/support/release.py @@ -151,12 +151,17 @@ if __name__ == '__main__': fmt_repo.add(changelog) fmt_repo.commit('-m', 'Update version') - # Build the docs and package. + # Build the docs locally; the source zip is now built and attached to the + # release in CI by .github/workflows/release.yml, which also generates a + # SLSA provenance attestation for it. run = Runner(fmt_repo.dir) run('cmake', '.') - run('make', 'doc', 'package_source') + run('make', 'doc') - # Create a release on GitHub. + # Create a draft release on GitHub. The release workflow triggers on + # `release: created`, builds the source zip from `target_commitish`, and + # attaches the zip plus *.intoto.jsonl provenance to this draft. After + # reviewing the draft, the maintainer clicks Publish to finalize. fmt_repo.push('origin', 'release') auth_headers = {'Authorization': 'token ' + os.getenv('FMT_TOKEN')} req = urllib.request.Request( @@ -169,20 +174,6 @@ if __name__ == '__main__': if response.status != 201: raise Exception(f'Failed to create a release ' + '{response.status} {response.reason}') - response_data = json.loads(response.read().decode('utf-8')) - id = response_data['id'] - - # Upload the package. - uploads_url = 'https://uploads.github.com/repos/fmtlib/fmt/releases' - package = 'fmt-{}.zip'.format(version) - req = urllib.request.Request( - f'{uploads_url}/{id}/assets?name={package}', - headers={'Content-Type': 'application/zip'} | auth_headers, - data=open('build/fmt/' + package, 'rb').read(), method='POST') - with urllib.request.urlopen(req) as response: - if response.status != 201: - raise Exception(f'Failed to upload an asset ' - '{response.status} {response.reason}') short_version = '.'.join(version.split('.')[:-1]) check_call(['./mkdocs', 'deploy', short_version])