Skip to content

Commit 4894c4a

Browse files
committed
feat(release): release on merge to the trunk, not on a tag
Implements the approved publishing policy (.github-config#17) in the shared workflows: - release-check runs on the trunk only, reads the version from the merged manifest, and reports release=false when that version already has a GitHub release, so a push that keeps the version publishes nothing. The new manifest.py release command refuses prerelease versions, a Helm chart that disagrees with the manifest, and git dependencies, so stable pins released versions. - publish creates the tag and the GitHub release last, on the released commit, with a token from the release GitHub App, the only actor the release-tags-create ruleset lets create a v* tag. - crates, docker and publish run in the release environment, which a bbot-leads member approves. crates.io uses trusted publishing and skips a version already published. docker drops the dev tag; dev images go to GHCR. - The tag subcommand and release.sh, which cut tags by hand, are gone.
1 parent 0f515e0 commit 4894c4a

7 files changed

Lines changed: 163 additions & 159 deletions

File tree

‎.github/workflows/crates.yml‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,9 @@ on:
99
description: Repository script run before cargo publish. It may append to $GITHUB_ENV.
1010
type: string
1111
default: ""
12-
environment:
12+
version:
13+
description: version output of release-check.yml
1314
type: string
14-
default: release
15-
secrets:
16-
CARGO_REGISTRY_TOKEN:
1715
required: true
1816

1917
permissions:
@@ -22,7 +20,10 @@ permissions:
2220
jobs:
2321
crates:
2422
runs-on: ubuntu-24.04
25-
environment: ${{ inputs.environment }}
23+
environment: release
24+
permissions:
25+
contents: read
26+
id-token: write
2627
defaults:
2728
run:
2829
working-directory: ${{ inputs.working-directory }}
@@ -31,10 +32,23 @@ jobs:
3132
with:
3233
persist-credentials: false
3334
- run: rustup show active-toolchain || rustup toolchain install
34-
- if: inputs.setup-script != ''
35+
- id: published
36+
env:
37+
VERSION: ${{ inputs.version }}
38+
run: |
39+
crate=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].name')
40+
if curl -sf -o /dev/null -H "User-Agent: $GITHUB_REPOSITORY" "https://crates.io/api/v1/crates/$crate/$VERSION"; then
41+
echo "::notice::$crate $VERSION is already on crates.io"
42+
echo "skip=true" >> "$GITHUB_OUTPUT"
43+
fi
44+
- if: steps.published.outputs.skip != 'true' && inputs.setup-script != ''
3545
env:
3646
SCRIPT: ${{ inputs.setup-script }}
3747
run: '"./$SCRIPT"'
38-
- env:
39-
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
48+
- if: steps.published.outputs.skip != 'true'
49+
id: auth
50+
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
51+
- if: steps.published.outputs.skip != 'true'
52+
env:
53+
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
4054
run: cargo publish --locked

‎.github/workflows/docker.yml‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@ on:
1010
description: version output of release-check.yml
1111
type: string
1212
required: true
13-
prerelease:
14-
type: string
15-
required: true
1613
major:
1714
type: string
1815
required: true
@@ -57,11 +54,10 @@ jobs:
5754
build:
5855
needs: plan
5956
runs-on: ubuntu-24.04
57+
environment: release
6058
strategy:
6159
matrix:
6260
build: ${{ fromJSON(needs.plan.outputs.builds) }}
63-
env:
64-
RELEASE: ${{ inputs.prerelease == 'false' }}
6561
steps:
6662
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
6763
with:
@@ -80,11 +76,10 @@ jobs:
8076
suffix=${{ matrix.build.suffix }}
8177
tags: |
8278
type=raw,value=${{ inputs.version }}
83-
type=raw,value=dev,enable=${{ env.RELEASE == 'false' }}
84-
type=raw,value=latest,enable=${{ env.RELEASE }}
85-
type=raw,value=stable,enable=${{ env.RELEASE }}
86-
type=raw,value=${{ inputs.major }}.${{ inputs.minor }},enable=${{ env.RELEASE }}
87-
type=raw,value=${{ inputs.major }},enable=${{ env.RELEASE }}
79+
type=raw,value=latest
80+
type=raw,value=stable
81+
type=raw,value=${{ inputs.major }}.${{ inputs.minor }}
82+
type=raw,value=${{ inputs.major }}
8883
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
8984
with:
9085
context: .
@@ -99,6 +94,7 @@ jobs:
9994
if: inputs.description
10095
needs: build
10196
runs-on: ubuntu-24.04
97+
environment: release
10298
steps:
10399
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
104100
with:

‎.github/workflows/publish.yml‎

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ name: Publish release
22
on:
33
workflow_call:
44
inputs:
5+
release-app-id:
6+
description: App ID of the release GitHub App
7+
type: string
8+
required: true
59
version:
610
description: version output of release-check.yml, which the caller runs first
711
type: string
812
required: true
9-
prerelease:
10-
description: prerelease output of release-check.yml
13+
tag:
14+
description: tag output of release-check.yml. Created here, on the commit being released, after every artifact is published.
1115
type: string
1216
required: true
1317
images:
@@ -38,16 +42,26 @@ on:
3842
registry-token:
3943
description: Token for pulling private images; omit for public images
4044
required: false
45+
release-app-key:
46+
description: Private key of the release GitHub App, the only actor the tag rulesets let create a v* tag
47+
required: true
4148

4249
permissions:
4350
contents: read
4451

4552
jobs:
4653
release:
4754
runs-on: ubuntu-24.04
55+
environment: release
4856
permissions:
49-
contents: write
57+
contents: read
5058
steps:
59+
- id: app
60+
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
61+
with:
62+
app-id: ${{ inputs.release-app-id }}
63+
private-key: ${{ secrets.release-app-key }}
64+
permission-contents: write
5165
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
5266
with:
5367
persist-credentials: false
@@ -67,26 +81,24 @@ jobs:
6781
with:
6882
path: .
6983
format: spdx-json
70-
output-file: sbom/${{ github.event.repository.name }}-${{ github.ref_name }}.spdx.json
84+
output-file: sbom/${{ github.event.repository.name }}-${{ inputs.tag }}.spdx.json
7185
upload-artifact: false
7286
upload-release-assets: false
7387
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
7488
- env:
75-
GH_TOKEN: ${{ github.token }}
76-
TAG: ${{ github.ref_name }}
89+
GH_TOKEN: ${{ steps.app.outputs.token }}
90+
TAG: ${{ inputs.tag }}
7791
VERSION: ${{ inputs.version }}
78-
PRERELEASE: ${{ inputs.prerelease }}
7992
CHANGELOG: ${{ inputs.changelog }}
8093
ASSETS: ${{ inputs.assets }}
8194
run: |
82-
flags=(--verify-tag)
95+
flags=(--target "$GITHUB_SHA")
8396
if [ -n "$CHANGELOG" ]; then
8497
uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py notes "$CHANGELOG" "$VERSION" > "$RUNNER_TEMP/notes.md"
8598
flags+=(--notes-file "$RUNNER_TEMP/notes.md")
8699
else
87100
flags+=(--generate-notes)
88101
fi
89-
[ "$PRERELEASE" = true ] && flags+=(--prerelease)
90102
gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1 || gh release create "$TAG" -R "$GITHUB_REPOSITORY" "${flags[@]}"
91103
shopt -s globstar nullglob
92104
files=(sbom/*.spdx.json)
@@ -97,16 +109,24 @@ jobs:
97109
if: inputs.images != '[]'
98110
needs: release
99111
runs-on: ubuntu-24.04
112+
environment: release
100113
permissions:
101-
contents: write
114+
contents: read
102115
strategy:
103116
matrix:
104117
image: ${{ fromJSON(inputs.images) }}
105118
steps:
106119
- id: name
107120
env:
108121
IMAGE: ${{ matrix.image }}
109-
run: echo "file=$(tr '/:@' '___' <<<"$IMAGE")-${GITHUB_REF_NAME}.spdx.json" >> "$GITHUB_OUTPUT"
122+
TAG: ${{ inputs.tag }}
123+
run: echo "file=$(tr '/:@' '___' <<<"$IMAGE")-${TAG}.spdx.json" >> "$GITHUB_OUTPUT"
124+
- id: app
125+
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
126+
with:
127+
app-id: ${{ inputs.release-app-id }}
128+
private-key: ${{ secrets.release-app-key }}
129+
permission-contents: write
110130
- id: auth
111131
env:
112132
TOKEN: ${{ secrets.registry-token }}
@@ -125,7 +145,7 @@ jobs:
125145
upload-artifact: false
126146
upload-release-assets: false
127147
- env:
128-
GH_TOKEN: ${{ github.token }}
129-
TAG: ${{ github.ref_name }}
148+
GH_TOKEN: ${{ steps.app.outputs.token }}
149+
TAG: ${{ inputs.tag }}
130150
FILE: ${{ steps.name.outputs.file }}
131151
run: gh release upload "$TAG" -R "$GITHUB_REPOSITORY" --clobber "$FILE"

‎.github/workflows/release-check.yml‎

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,18 @@ on:
1111
default: ""
1212
outputs:
1313
version:
14-
description: Manifest version, equal to the tag. Every tracked Helm Chart.yaml version and appVersion must equal it too.
14+
description: Manifest version of the merged commit. Every tracked Helm Chart.yaml version and appVersion must equal it.
1515
value: ${{ jobs.check.outputs.version }}
16-
prerelease:
17-
description: true for vX.Y.Z-rc.N tags
18-
value: ${{ jobs.check.outputs.prerelease }}
19-
semver:
20-
description: The tag without its v prefix
21-
value: ${{ jobs.check.outputs.semver }}
16+
tag:
17+
description: v<version>, created by publish.yml once everything is published
18+
value: ${{ jobs.check.outputs.tag }}
2219
major:
2320
value: ${{ jobs.check.outputs.major }}
2421
minor:
2522
value: ${{ jobs.check.outputs.minor }}
23+
release:
24+
description: false when the version already has a GitHub release, so a push that keeps the version publishes nothing
25+
value: ${{ jobs.check.outputs.release }}
2626

2727
permissions:
2828
contents: read
@@ -31,15 +31,17 @@ jobs:
3131
check:
3232
runs-on: ubuntu-24.04
3333
outputs:
34-
version: ${{ steps.tag.outputs.version }}
35-
prerelease: ${{ steps.tag.outputs.prerelease }}
36-
semver: ${{ steps.tag.outputs.semver }}
37-
major: ${{ steps.tag.outputs.major }}
38-
minor: ${{ steps.tag.outputs.minor }}
34+
version: ${{ steps.manifest.outputs.version }}
35+
tag: ${{ steps.manifest.outputs.tag }}
36+
major: ${{ steps.manifest.outputs.major }}
37+
minor: ${{ steps.manifest.outputs.minor }}
38+
release: ${{ steps.new.outputs.release }}
3939
steps:
40-
- if: github.ref_type != 'tag'
40+
- if: github.ref != format('refs/heads/{0}', github.event.repository.default_branch)
41+
env:
42+
TRUNK: ${{ github.event.repository.default_branch }}
4143
run: |
42-
echo "::error::releases run on tag pushes only"
44+
echo "::error::releases run from $TRUNK only"
4345
exit 1
4446
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
4547
with:
@@ -51,15 +53,25 @@ jobs:
5153
path: .shared
5254
persist-credentials: false
5355
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
54-
- id: tag
56+
- id: manifest
5557
env:
5658
ROOT: ${{ inputs.working-directory }}
57-
TAG: ${{ github.ref_name }}
58-
run: uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py --root "$ROOT" tag "$TAG"
59+
run: uv run --no-project --with packaging --with pyyaml python .shared/scripts/manifest.py --root "$ROOT" release
5960
- if: inputs.check-script != ''
6061
env:
6162
SCRIPT: ${{ inputs.check-script }}
62-
TAG: ${{ github.ref_name }}
63+
TAG: ${{ steps.manifest.outputs.tag }}
6364
run: |
6465
read -ra cmd <<<"$SCRIPT"
6566
"./${cmd[0]}" "${cmd[@]:1}" "$TAG"
67+
- id: new
68+
env:
69+
GH_TOKEN: ${{ github.token }}
70+
TAG: ${{ steps.manifest.outputs.tag }}
71+
run: |
72+
if gh release view "$TAG" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
73+
echo "::notice::$TAG is already released, nothing to publish"
74+
echo "release=false" >> "$GITHUB_OUTPUT"
75+
else
76+
echo "release=true" >> "$GITHUB_OUTPUT"
77+
fi

‎scripts/manifest.py‎

Lines changed: 27 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@
1010
from enum import Enum
1111
from pathlib import Path
1212

13-
import yaml
1413
from packaging.specifiers import SpecifierSet
1514
from packaging.version import Version
1615

17-
TAG = re.compile(r"^v(?P<version>\d+\.\d+\.\d+(?:-rc\.(?P<rc>\d+))?)$")
1816
MANIFESTS = ("pyproject.toml", "Cargo.toml", "package.json")
1917

2018

@@ -140,25 +138,35 @@ def cmd_matrix(args):
140138
)
141139

142140

143-
def cmd_tag(args):
144-
match = TAG.match(args.tag)
145-
if not match:
146-
sys.exit(f"tag {args.tag} is not vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rc.N")
141+
def git_dependencies(root):
142+
pyproject = load(root / "pyproject.toml")
143+
project = pyproject.get("project", {})
144+
found = [d for d in project.get("dependencies", []) if "git+" in d]
145+
for group in project.get("optional-dependencies", {}).values():
146+
found += [d for d in group if "git+" in d]
147+
tool = pyproject.get("tool", {})
148+
found += [name for name, source in tool.get("uv", {}).get("sources", {}).items() if "git" in source]
149+
for table in ("dependencies", "dev-dependencies"):
150+
found += [name for name, spec in tool.get("poetry", {}).get(table, {}).items() if isinstance(spec, dict) and "git" in spec]
151+
cargo = load(root / tool.get("maturin", {}).get("manifest-path", "Cargo.toml"))
152+
for table in ("dependencies", "build-dependencies"):
153+
found += [name for name, spec in cargo.get(table, {}).items() if isinstance(spec, dict) and "git" in spec]
154+
return found
155+
156+
157+
def cmd_release(args):
147158
declared = version(args.root)
148-
if Version(declared) != Version(match["version"]):
149-
sys.exit(f"tag {args.tag} does not match manifest version {declared}")
159+
parsed = Version(declared)
160+
if parsed.is_prerelease or parsed.is_devrelease or str(parsed) != declared:
161+
sys.exit(f"{declared} is not a MAJOR.MINOR.PATCH release version")
150162
for chart in charts(args.root):
151163
for field, value in chart_fields(chart).items():
152-
if value != match["version"]:
153-
sys.exit(f"tag {args.tag} does not match {chart} {field} {value}")
154-
major, minor, _ = match["version"].split("-")[0].split(".")
155-
emit(
156-
version=declared,
157-
semver=match["version"],
158-
major=major,
159-
minor=minor,
160-
prerelease=str(match["rc"] is not None).lower(),
161-
)
164+
if value != declared:
165+
sys.exit(f"{chart} {field} {value} does not match manifest version {declared}")
166+
pinned = git_dependencies(args.root)
167+
if pinned:
168+
sys.exit(f"stable must pin released versions, not git sources: {', '.join(pinned)}")
169+
emit(version=declared, tag=f"v{declared}", major=str(parsed.major), minor=str(parsed.minor))
162170

163171

164172
def cmd_notes(args):
@@ -177,16 +185,6 @@ def cmd_notes(args):
177185
print("\n".join(section).strip())
178186

179187

180-
def cmd_caller(args):
181-
for path in sorted((args.root / ".github" / "workflows").glob("*.y*ml")):
182-
for job in (yaml.safe_load(path.read_text()) or {}).get("jobs", {}).values():
183-
if "/.github/workflows/release-check.yml@" in str(job.get("uses", "")):
184-
given = job.get("with", {})
185-
print(json.dumps({"working-directory": given.get("working-directory", "."), "check-script": given.get("check-script", "")}))
186-
return
187-
sys.exit(f"no job in {args.root}/.github/workflows calls release-check.yml")
188-
189-
190188
def cmd_images(args):
191189
listed = subprocess.run(["git", "ls-files", "-z", "--", "Dockerfile*"], cwd=args.root, capture_output=True, text=True, check=True)
192190
builds = []
@@ -204,14 +202,11 @@ def main():
204202
parser.add_argument("--root", type=Path, default=Path("."))
205203
sub = parser.add_subparsers(required=True)
206204
sub.add_parser("matrix").set_defaults(func=cmd_matrix)
207-
tag = sub.add_parser("tag")
208-
tag.add_argument("tag")
209-
tag.set_defaults(func=cmd_tag)
205+
sub.add_parser("release").set_defaults(func=cmd_release)
210206
notes = sub.add_parser("notes")
211207
notes.add_argument("changelog", type=Path)
212208
notes.add_argument("version")
213209
notes.set_defaults(func=cmd_notes)
214-
sub.add_parser("caller").set_defaults(func=cmd_caller)
215210
images = sub.add_parser("images")
216211
images.add_argument("image")
217212
images.add_argument("version")

0 commit comments

Comments
 (0)