-
Notifications
You must be signed in to change notification settings - Fork 493
229 lines (220 loc) · 9.59 KB
/
Copy pathpublish-release.yml
File metadata and controls
229 lines (220 loc) · 9.59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
name: Publish release
# Merging a release PR is the trigger. Nothing here is dispatched by hand, so a published release is
# always a reviewed commit on main, and the tag is created by this workflow rather than by a person
# with a terminal.
on:
push:
branches: [main]
permissions:
contents: read
jobs:
# Every push to main runs this, and almost none of them are releases. This job decides which, and
# it decides from the merged pull request rather than from the commit message, because a commit
# message is something anybody can write.
metadata:
name: classify
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
is_release: ${{ steps.release.outputs.is_release }}
version: ${{ steps.release.outputs.version }}
steps:
- id: pull-request
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const { data: pulls } =
await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner, repo, commit_sha: context.sha,
});
const pull = pulls.find((candidate) =>
candidate.base.ref === "main" &&
candidate.merged_at !== null &&
candidate.merge_commit_sha === context.sha
);
if (!pull) {
// Not a merge commit of a reviewed PR. That is most pushes; it is not an error.
core.setOutput("head_ref", "");
core.setOutput("trusted", "false");
return;
}
// A release branch name is not enough on its own: a fork can open a PR from a branch
// with any name it likes. The branch must be in this repository and the PR must carry
// the label, which only somebody with write access can add.
const trusted =
/^release\/publish\/v\d+\.\d+\.\d+$/.test(pull.head.ref) &&
pull.head.repo?.full_name === `${owner}/${repo}` &&
pull.labels.some((label) => label.name === "release");
core.setOutput("head_ref", pull.head.ref);
core.setOutput("trusted", String(trusted));
- id: release
name: Decide
env:
HEAD_REF: ${{ steps.pull-request.outputs.head_ref }}
TRUSTED: ${{ steps.pull-request.outputs.trusted }}
run: |
set -euo pipefail
if [ "$TRUSTED" != true ]; then
echo "is_release=false" >> "$GITHUB_OUTPUT"
echo "Not a release commit."
exit 0
fi
version="${HEAD_REF#release/publish/}"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Releasing $version"
# The version in the tree has to agree with the branch that is publishing it. They are written by
# the same workflow, so disagreement means something was edited by hand after review.
verify:
name: verify
needs: metadata
if: needs.metadata.outputs.is_release == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
- env:
VERSION: ${{ needs.metadata.outputs.version }}
run: |
set -euo pipefail
test "v$(bun -e 'console.log(require("./package.json").version)')" = "$VERSION"
grep -q "^## ${VERSION#v}$" CHANGELOG.md || {
echo "::error::CHANGELOG.md has no section for ${VERSION#v}."
exit 1
}
# The same checks CI runs, against the commit being published. This is the gate: nothing is built
# or tagged unless they pass here, on this exact tree.
checks:
needs: [metadata, verify]
if: needs.metadata.outputs.is_release == 'true'
uses: $/.github/workflows/ci.yml
permissions:
contents: read
# One image, built once. Everything downstream refers to it by digest, so what was tested is what
# is deployed and there is no second build to disagree with the first.
image:
name: image
needs: [metadata, verify, checks]
if: needs.metadata.outputs.is_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# The attestation is signed with the workflow's own OIDC identity, so there is no key to hold
# and the signature says which workflow, repository and commit produced the image.
id-token: write
attestations: write
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
# The version, the commit, and a moving latest. The version tag is the one to deploy; the
# commit tag is how you find out what a running image actually contains.
tags: |
ghcr.io/copilotkit/openbot:${{ needs.metadata.outputs.version }}
ghcr.io/copilotkit/openbot:${{ github.sha }}
ghcr.io/copilotkit/openbot:latest
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
# BuildKit's own attestations above travel inside the image. This one is the record GitHub
# holds, and it is what `gh attestation verify oci://ghcr.io/copilotkit/openbot:vX.Y.Z
# -R CopilotKit/OpenBot` checks before anybody deploys it. Bound to the digest, never a tag,
# because a tag can be moved to point at something else afterwards.
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/copilotkit/openbot
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
# The tag and the release, last, so nothing is announced that was not built. The manifest is the
# useful artefact: it pins the digest, so a deploy or a rollback names an exact image rather than a
# tag somebody could move.
github-release:
name: tag and release
needs: [metadata, verify, checks, image]
if: needs.metadata.outputs.is_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# No credential is left in the runner: the tag is created through the API below rather than
# with `git push`, so nothing here needs one, and no later step or action can read one.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Write the image manifest
env:
VERSION: ${{ needs.metadata.outputs.version }}
DIGEST: ${{ needs.image.outputs.digest }}
COMMIT: ${{ github.sha }}
run: |
set -euo pipefail
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
# `jq`, not `bun`: this job deliberately checks out without credentials and installs no
# toolchain, so reaching for the repository's runtime here is a step that was never taken.
# It was, and the tag was never cut: the manifest step died on `bun: command not found`
# after the image had already been pushed, which is the one point in the release where a
# failure leaves a published image with nothing pointing at it.
jq -n \
--arg version "$VERSION" \
--arg digest "$DIGEST" \
--arg commit "$COMMIT" \
--arg repository "ghcr.io/copilotkit/openbot" \
'{
version: $version,
commit: $commit,
images: {
openbot: {
repository: $repository,
digest: $digest,
reference: ($repository + "@" + $digest),
},
},
}' > container-images.json
cat container-images.json
- name: Tag and publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.metadata.outputs.version }}
run: |
set -euo pipefail
# The notes are the section a person wrote, not a list of commits.
awk -v v="## ${VERSION#v}" '$0==v{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md > notes.md
test -s notes.md
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$VERSION" >/dev/null 2>&1; then
echo "$VERSION is already tagged; only refreshing the release."
else
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$VERSION" -f "sha=$GITHUB_SHA" >/dev/null
fi
if gh release view "$VERSION" >/dev/null 2>&1; then
gh release edit "$VERSION" --title "$VERSION" --notes-file notes.md
gh release upload "$VERSION" container-images.json --clobber
else
gh release create "$VERSION" container-images.json \
--title "$VERSION" --notes-file notes.md
fi