Skip to content

Commit 178f90e

Browse files
authored
ci: implement immutable releases support with actions/attest (#689)
* ci: implement immutable releases support with actions/attest Bring the release pipeline to full parity with ld-relay's post-immutable-releases pattern: draft-first releases, split release-please with manual tag creation, actions/attest@v4 for binaries and container images, and a final un-draft step. Made-with: Cursor * ci: disable goreleaser github release, extend upload glob for windows .zip Without release.disable, goreleaser would clobber or duplicate the draft release created by release-please (release.use_existing_draft wasn't set and the default draft is false). Matches ld-relay: release-please owns the GitHub release, goreleaser only builds artifacts and pushes images. Also extend the gh release upload glob to include *.zip since ldcli builds for Windows (goreleaser defaults to zip there). Made-with: Cursor * ci: make manual-publish un-draft wait on npm job too Match release-please.yml's `publish-release` needs list so an npm failure keeps the release as a visible draft. Use `!cancelled() && !failure()` to allow the un-draft to proceed when the npm job is skipped via dry-run-npm. Made-with: Cursor
1 parent ac75137 commit 178f90e

6 files changed

Lines changed: 198 additions & 83 deletions

File tree

.github/actions/publish/action.yml

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@ inputs:
2424
required: true
2525

2626
outputs:
27-
hashes:
28-
description: sha256sum hashes of built artifacts
29-
value: ${{ steps.hash.outputs.hashes }}
27+
checksum_file:
28+
description: path to the sha256 checksums file generated by goreleaser
29+
value: ${{ steps.binary.outputs.checksum_file }}
30+
images_and_digests:
31+
description: built docker image names and digests in JSON format
32+
value: ${{ steps.image.outputs.images_and_digests }}
3033

3134
runs:
3235
using: composite
@@ -76,8 +79,27 @@ runs:
7679
with:
7780
name: ldcli
7881
path: dist/*
79-
- name: Hash build artifacts for provenance
80-
id: hash
82+
- name: Generate binary checksum file path
83+
id: binary
8184
shell: bash
8285
run: |
83-
echo "hashes=$(sha256sum dist/*.tar.gz | base64 -w0)" >> "$GITHUB_OUTPUT"
86+
# Extract path to checksums file generated by goreleaser from dist/artifacts.json
87+
set -euo pipefail
88+
89+
checksum_file=$(jq -r '.[] | select (.type=="Checksum") | .path' dist/artifacts.json)
90+
echo "checksum_file=$checksum_file" >> "$GITHUB_OUTPUT"
91+
- name: Output image and digest
92+
id: image
93+
shell: bash
94+
run: |
95+
# Extract image names and digests from goreleaser's dist/artifacts.json
96+
set -euo pipefail
97+
98+
echo "images_and_digests=$(jq -c '. | map(select (.type=="Docker Manifest") | .image=(.path | split(":")[0]) | .digest=(.extra | .Digest) | {image, digest})' dist/artifacts.json)" >> "$GITHUB_OUTPUT"
99+
- name: Upload Release Artifacts
100+
if: ${{ inputs.dry-run != 'true' }}
101+
shell: bash
102+
env:
103+
GITHUB_TOKEN: ${{ inputs.token }}
104+
run: |
105+
gh release upload "${{ inputs.tag }}" ./dist/*.tar.gz ./dist/*.zip ./dist/*.txt --clobber

.github/workflows/manual-publish.yml

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Manually Publish Images and Artifacts
22
on:
33
workflow_dispatch:
44
inputs:
5-
dry-run:
5+
dry_run:
66
default: true
77
description: 'Skip publishing to DockerHub and Homebrew'
88
required: false
@@ -13,20 +13,26 @@ on:
1313
required: false
1414
type: boolean
1515
tag:
16-
description: 'Tag to upload binary artifacts to'
16+
description: 'Tag of an existing draft release to upload binary artifacts to.'
1717
required: true
1818
type: string
19+
publish_release:
20+
description: 'Publish (un-draft) the release after all artifacts are uploaded?'
21+
type: boolean
22+
required: false
23+
default: true
1924

2025
jobs:
2126

2227
release-ldcli:
2328
permissions:
24-
id-token: write # Needed to obtain Docker tokens
29+
id-token: write # Needed to obtain Docker tokens and to sign attestations
2530
contents: write # Needed to upload release artifacts
2631
packages: read # Needed to load goreleaser-cross image
32+
attestations: write # Needed for artifact attestations
2733
runs-on: ubuntu-latest
2834
outputs:
29-
hashes: ${{ steps.publish.outputs.hashes }}
35+
images_and_digests: ${{ steps.publish.outputs.images_and_digests }}
3036
steps:
3137
- uses: actions/checkout@v4
3238
name: Checkout
@@ -44,16 +50,39 @@ jobs:
4450
- uses: ./.github/actions/publish
4551
id: publish
4652
with:
47-
dry-run: ${{ inputs.dry-run }}
53+
dry-run: ${{ inputs.dry_run }}
4854
token: ${{ secrets.GITHUB_TOKEN }}
49-
homebrew-gh-secret: ${{secrets.HOMEBREW_DEPLOY_KEY}}
55+
homebrew-gh-secret: ${{ secrets.HOMEBREW_DEPLOY_KEY }}
5056
tag: ${{ inputs.tag }}
5157
ghcr_token: "${{ secrets.GITHUB_TOKEN }}"
5258

59+
- name: Attest binary artifacts
60+
if: ${{ !inputs.dry_run }}
61+
uses: actions/attest@v4
62+
with:
63+
subject-checksums: ${{ steps.publish.outputs.checksum_file }}
64+
65+
attest-image-provenance:
66+
needs: [release-ldcli]
67+
if: ${{ !inputs.dry_run }}
68+
runs-on: ubuntu-latest
69+
permissions:
70+
id-token: write
71+
attestations: write
72+
strategy:
73+
matrix:
74+
images_and_digests: ${{ fromJson(needs.release-ldcli.outputs.images_and_digests) }}
75+
steps:
76+
- name: Attest container image
77+
uses: actions/attest@v4
78+
with:
79+
subject-name: ${{ matrix.images_and_digests.image }}
80+
subject-digest: ${{ matrix.images_and_digests.digest }}
81+
5382
release-ldcli-npm:
5483
runs-on: ubuntu-latest
5584
if: ${{ inputs.dry-run-npm == false }}
56-
needs: ['release-ldcli']
85+
needs: [release-ldcli]
5786
permissions:
5887
actions: read
5988
id-token: write
@@ -76,18 +105,23 @@ jobs:
76105
name: Publish NPM Package
77106
uses: ./.github/actions/publish-npm
78107
with:
79-
dry-run: ${{ inputs.dry-run }}
80-
prerelease: ${{ inputs.prerelease }}
108+
dry-run: ${{ inputs.dry_run }}
109+
prerelease: 'false'
81110

82-
release-ldcli-provenance:
83-
needs: ['release-ldcli']
111+
publish-release:
112+
needs: [release-ldcli, attest-image-provenance, release-ldcli-npm]
113+
# !cancelled() && !failure() lets this job run when release-ldcli-npm was
114+
# skipped (dry-run-npm: true) but still blocks if any needed job failed.
115+
if: ${{ !cancelled() && !failure() && !inputs.dry_run && inputs.publish_release }}
116+
runs-on: ubuntu-latest
84117
permissions:
85-
actions: read
86-
id-token: write
87-
contents: write
88-
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0
89-
with:
90-
base64-subjects: "${{ needs.release-ldcli.outputs.hashes }}"
91-
upload-assets: true
92-
upload-tag-name: ${{ inputs.tag }}
93-
provenance-name: ${{ format('ldcli_{0}_multiple_provenance.intoto.jsonl', inputs.tag) }}
118+
contents: write
119+
steps:
120+
- name: Publish release
121+
env:
122+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
123+
TAG_NAME: ${{ inputs.tag }}
124+
run: >
125+
gh release edit "$TAG_NAME"
126+
--repo ${{ github.repository }}
127+
--draft=false
Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,65 @@
11
name: Run Release Please
22

33
on:
4-
pull_request:
54
push:
65
branches:
76
- main
87

98
jobs:
109
release-please:
1110
runs-on: ubuntu-latest
12-
if: github.event_name == 'push'
1311
permissions:
1412
contents: write
1513
pull-requests: write
1614
outputs:
1715
release_created: ${{ steps.release.outputs.release_created }}
1816
tag_name: ${{ steps.release.outputs.tag_name }}
1917
steps:
20-
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1
18+
# Create any releases in release, then create tags, and then optionally create any new PRs.
19+
- uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0
2120
id: release
2221
with:
23-
token: ${{secrets.GITHUB_TOKEN}}
22+
token: ${{ secrets.GITHUB_TOKEN }}
23+
skip-github-pull-request: true
24+
25+
# Need the repository content to be able to create and push a tag.
26+
- uses: actions/checkout@v4
27+
if: ${{ steps.release.outputs.release_created == 'true' }}
28+
29+
- name: Create release tag
30+
if: ${{ steps.release.outputs.release_created == 'true' }}
31+
env:
32+
TAG_NAME: ${{ steps.release.outputs.tag_name }}
33+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34+
run: |
35+
if gh api "repos/${{ github.repository }}/git/ref/tags/${TAG_NAME}" >/dev/null 2>&1; then
36+
echo "Tag ${TAG_NAME} already exists, skipping creation."
37+
else
38+
echo "Creating tag ${TAG_NAME}."
39+
git config user.name "github-actions[bot]"
40+
git config user.email "github-actions[bot]@users.noreply.github.com"
41+
git tag "${TAG_NAME}"
42+
git push origin "${TAG_NAME}"
43+
fi
44+
45+
- uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0
46+
if: ${{ steps.release.outputs.release_created != 'true' }}
47+
id: release-prs
48+
with:
49+
token: ${{ secrets.GITHUB_TOKEN }}
50+
skip-github-release: true
2451

2552
release-ldcli:
2653
permissions:
27-
id-token: write # Needed to obtain Docker tokens
54+
id-token: write # Needed to obtain Docker tokens and to sign attestations
2855
contents: write # Needed to upload release artifacts
2956
packages: read # Needed to load goreleaser-cross image
30-
needs: [ release-please ]
31-
if: needs.release-please.outputs.release_created == 'true' || github.event_name == 'pull_request'
57+
attestations: write # Needed for artifact attestations
58+
needs: [release-please]
59+
if: needs.release-please.outputs.release_created == 'true'
3260
runs-on: ubuntu-22.04-8core-32gb
3361
outputs:
34-
hashes: ${{ steps.publish.outputs.hashes }}
62+
images_and_digests: ${{ steps.publish.outputs.images_and_digests }}
3563
steps:
3664
- uses: actions/checkout@v4
3765
name: Checkout
@@ -49,16 +77,36 @@ jobs:
4977
- uses: ./.github/actions/publish
5078
id: publish
5179
with:
52-
dry-run: ${{ github.event_name == 'pull_request' }}
53-
snapshot: ${{ github.event_name == 'pull_request' }}
80+
dry-run: 'false'
5481
token: ${{ secrets.GITHUB_TOKEN }}
55-
homebrew-gh-secret: ${{secrets.HOMEBREW_DEPLOY_KEY}}
82+
homebrew-gh-secret: ${{ secrets.HOMEBREW_DEPLOY_KEY }}
5683
tag: ${{ needs.release-please.outputs.tag_name }}
5784
ghcr_token: "${{ secrets.GITHUB_TOKEN }}"
5885

86+
- name: Attest binary artifacts
87+
uses: actions/attest@v4
88+
with:
89+
subject-checksums: ${{ steps.publish.outputs.checksum_file }}
90+
91+
attest-image-provenance:
92+
needs: [release-ldcli]
93+
runs-on: ubuntu-latest
94+
permissions:
95+
id-token: write
96+
attestations: write
97+
strategy:
98+
matrix:
99+
images_and_digests: ${{ fromJson(needs.release-ldcli.outputs.images_and_digests) }}
100+
steps:
101+
- name: Attest container image
102+
uses: actions/attest@v4
103+
with:
104+
subject-name: ${{ matrix.images_and_digests.image }}
105+
subject-digest: ${{ matrix.images_and_digests.digest }}
106+
59107
release-ldcli-npm:
60108
runs-on: ubuntu-latest
61-
needs: ['release-please', 'release-ldcli']
109+
needs: [release-please, release-ldcli]
62110
permissions:
63111
id-token: write
64112
contents: write
@@ -78,18 +126,21 @@ jobs:
78126
name: Publish NPM Package
79127
uses: ./.github/actions/publish-npm
80128
with:
81-
dry-run: ${{ github.event_name == 'pull_request' }}
129+
dry-run: 'false'
82130
prerelease: 'false'
83131

84-
release-ldcli-provenance:
85-
needs: ['release-please', 'release-ldcli']
132+
publish-release:
133+
needs: [release-please, release-ldcli, attest-image-provenance, release-ldcli-npm]
134+
if: needs.release-please.outputs.release_created == 'true'
135+
runs-on: ubuntu-latest
86136
permissions:
87-
actions: read
88-
id-token: write
89137
contents: write
90-
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0
91-
with:
92-
base64-subjects: "${{ needs.release-ldcli.outputs.hashes }}"
93-
upload-assets: true
94-
upload-tag-name: ${{ needs.release-please.outputs.tag_name }}
95-
provenance-name: ${{ format('ldcli_{0}_multiple_provenance.intoto.jsonl', needs.release-please.outputs.tag_name) }}
138+
steps:
139+
- name: Publish release
140+
env:
141+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
142+
TAG_NAME: ${{ needs.release-please.outputs.tag_name }}
143+
run: >
144+
gh release edit "$TAG_NAME"
145+
--repo ${{ github.repository }}
146+
--draft=false

.goreleaser.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ version: 2
22

33
project_name: ldcli
44

5+
release:
6+
# release-please owns the GitHub release lifecycle (creates the draft, and
7+
# publish-release un-drafts at the end). Goreleaser only builds binaries and
8+
# pushes images; the workflow's `gh release upload` step attaches artifacts
9+
# to the draft. See .github/workflows/release-please.yml for the full flow.
10+
disable: true
11+
512
env:
613
- GO111MODULE=on # Ensure we aren't using anything in GOPATH when building
714
- CGO_ENABLED=1 # Needed for SQLite support

0 commit comments

Comments
 (0)