Skip to content

Commit 925da22

Browse files
Merge pull request #17 from cloudfall-dev/ci/shipmill-releases
ci: cut releases with shipmill from the CHANGELOG
2 parents 64ca872 + a0cad98 commit 925da22

7 files changed

Lines changed: 306 additions & 77 deletions

File tree

‎.claude/settings.json‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"extraKnownMarketplaces": {
3+
"shipmill": {
4+
"source": {
5+
"source": "github",
6+
"repo": "shipmill/shipmill"
7+
},
8+
"autoUpdate": true
9+
}
10+
},
11+
"enabledPlugins": {
12+
"shipmill@shipmill": true
13+
}
14+
}

‎.github/shipmill.toml‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# shipmill reads this file on every run: https://github.com/shipmill/shipmill
2+
name = "cloudfall"
3+
4+
# off: do nothing; dry-run: show the release commit it would make; release: release
5+
mode = "dry-run"
6+
branch = "main"
7+
8+
# pyproject: [project] version in pyproject.toml and uv.lock; none: tags and CHANGELOG only
9+
version_files = "pyproject"
10+
11+
# Commands run after the version is written, before the commit; a failure stops the release
12+
after_stamp = ["uv lock --check"]
13+
14+
[changelog]
15+
path = "CHANGELOG.md"
16+
style = "keep-a-changelog"
17+
18+
[bump]
19+
from = "headings"
20+
major = ["Breaking"]
21+
minor = ["Added", "Changed", "Deprecated", "Removed"]
22+
patch = ["Fixed", "Security"]
23+
24+
[gates]
25+
# An open issue with this label holds the listed lanes
26+
blocker_label = "release-blocker"
27+
blocker_lanes = ["rc", "stable"]
28+
# Inclusive UTC date ranges when the listed lanes hold, such as "2026-12-24..2027-01-02"
29+
freeze = []
30+
freeze_lanes = ["dev", "rc", "stable"]
31+
32+
# Each lane releases when one of its triggers is due and no gate holds it; every lane can
33+
# also be started by hand. One run releases one lane, in the order hotfix, stable, rc, dev.
34+
35+
# [lanes.dev] # X.Y.Z.devN after every batch of merges
36+
# quiet_minutes = 30
37+
# dispatch = ["publish.yml"]
38+
39+
[lanes.rc] # X.Y.ZrcN from main, after each batch of merges
40+
quiet_minutes = 30
41+
github_release = true
42+
dispatch = ["publish.yml"]
43+
44+
# X.Y.Z, promoted from an rc soaked 3 days, once the milestone titled with the version
45+
# (0.6.0) has closed issues and none open: the milestone is the only trigger, so it gates
46+
[lanes.stable]
47+
promote_from = "rc"
48+
min_soak_days = 3
49+
milestone = true
50+
github_release = true
51+
dispatch = ["publish.yml"]
52+
53+
[lanes.hotfix] # X.Y.(Z+1) from release/X.Y with chosen PRs, by hand only
54+
github_release = true
55+
dispatch = ["publish.yml"]
56+
57+
# Environments a release deploys to. The workflow runs on workflow_dispatch with `tag` and
58+
# `environment` inputs, and its job sets `environment: ${{ inputs.environment }}`, so
59+
# GitHub records a deployment for each run. With from or health, run
60+
# `uvx --from git+https://github.com/shipmill/shipmill@v0 shipmill init --operate`:
61+
# its workflow checks health every 10 minutes and promotes after the bake.
62+
# [environments.staging]
63+
# lane = "rc" # deploy every release of this lane when it lands
64+
# workflow = "deploy.yml"
65+
# health = "https://staging.example.com/health"
66+
#
67+
# [environments.production]
68+
# from = "staging" # promoted from staging once it baked there
69+
# workflow = "deploy.yml"
70+
# health = "https://example.com/health"
71+
# bake_minutes = 60 # how long staging stays healthy before promotion
72+
73+
# Lines a release rewrites besides the version files; each pattern matches exactly once.
74+
# The replacement can use {name}, {version}, {semver}, {minor} (X.Y), and {date}.
75+
# [[version_lines]]
76+
# file = "README.md"
77+
# pattern = 'cloudfall v\d+\.\d+\.\d+'
78+
# replace = "cloudfall v{version}"
79+
80+
# How far shipmill goes by itself: observe (report only), propose (open an issue saying what
81+
# it would release; a person starts the lane), or act (the default). An open issue labelled
82+
# shipmill-hold turns every act into propose until it is closed.
83+
# [autonomy]
84+
# release = "act"
85+
86+
# shipmill gate (launchd) starts a Claude Code session only when the repo needs one
87+
[agents]
88+
prompt = "/shipmill:github-issue-triage {repo} triage the new issues; merge when green"
89+
prs = true # true when the prompt lands PRs
90+
retry_hours = 24 # unchanged findings start a new session after this
91+
notify = true # a desktop notification when a session waits on you
92+
remind_hours = 4 # repeat it while the session still waits
93+
max_wait_minutes = 15 # stop a session that waited this long; 0: never
94+
app_id = 5209412 # gated sessions write as shipmill-romamo[bot]
95+
mode = "headless" # nobody attaches: sessions ask on GitHub with needs-decision

‎.github/workflows/ci.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@ name: CI
66
branches:
77
- main
88
pull_request:
9+
# shipmill's release workflow and publish.yml run these checks on the commit they release
10+
workflow_call:
11+
inputs:
12+
ref:
13+
type: string
14+
required: true
915

1016
jobs:
1117
checks:
1218
runs-on: ubuntu-latest
1319
steps:
1420
- uses: actions/checkout@v7
21+
with:
22+
ref: ${{ inputs.ref }}
1523

1624
- uses: astral-sh/setup-uv@v10.1.0
1725
with:

‎.github/workflows/publish.yml‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
---
2+
name: Publish
3+
4+
# shipmill's release workflow starts this on each tag it pushes (.github/shipmill.toml)
5+
"on":
6+
workflow_dispatch:
7+
inputs:
8+
tag:
9+
description: Existing tag to publish, e.g. v0.6.0
10+
required: true
11+
12+
permissions:
13+
contents: read
14+
15+
concurrency:
16+
group: publish-${{ inputs.tag }}
17+
cancel-in-progress: false
18+
19+
env:
20+
TAG: ${{ inputs.tag }}
21+
22+
jobs:
23+
ci:
24+
uses: ./.github/workflows/ci.yml
25+
with:
26+
ref: refs/tags/${{ inputs.tag }}
27+
28+
build:
29+
needs: ci
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v7
33+
with:
34+
ref: refs/tags/${{ env.TAG }}
35+
persist-credentials: false
36+
37+
- uses: astral-sh/setup-uv@v10.1.0
38+
with:
39+
python-version: "3.14"
40+
41+
- name: Check the tag matches the project version
42+
run: test "v$(uv version --short)" = "$TAG"
43+
44+
- run: uv build --out-dir dist
45+
46+
- name: Check the sdist leaves out tooling
47+
run: |
48+
if tar -tzf dist/*.tar.gz | grep -E '^[^/]+/(\.github|\.agents?|\.claude|uv\.lock)(/|$)'; then
49+
exit 1
50+
fi
51+
52+
# The wheel carries the schema catalog and the Ansible engine as package
53+
# data; a project installed from PyPI has no checkout to fall back on, so
54+
# an empty bundle must fail the release rather than ship.
55+
- name: Check the wheel carries the bundled schemas and engine
56+
run: |
57+
uv run --no-project python - <<'PY'
58+
import sys
59+
import zipfile
60+
from pathlib import Path
61+
62+
wheel = next(Path("dist").glob("*.whl"))
63+
names = zipfile.ZipFile(wheel).namelist()
64+
for expected in ("cloudfall/_bundled/schemas/", "cloudfall_engine/_bundled/ansible/"):
65+
if not any(name.startswith(expected) for name in names):
66+
sys.exit(f"{wheel.name} carries nothing under {expected}")
67+
PY
68+
69+
# The published artifacts, exercised the way a user gets them: installed
70+
# into an empty environment with no repository in sight.
71+
- name: Smoke-test the wheel and the sdist
72+
run: |
73+
for dist in dist/*.whl dist/*.tar.gz; do
74+
uv run --isolated --no-project --python 3.14 --with "$dist" \
75+
cloudfall config validate --project config/examples
76+
uv run --isolated --no-project --python 3.14 --with "$dist" \
77+
cloudfall-engine inventory render --project config/examples
78+
done
79+
80+
- uses: actions/upload-artifact@v7
81+
with:
82+
name: dist
83+
path: dist/
84+
if-no-files-found: error
85+
86+
# PyPI trusted publishing: the workflow proves its identity with a
87+
# short-lived OIDC token, so no API token is stored anywhere.
88+
publish:
89+
needs: build
90+
runs-on: ubuntu-latest
91+
environment:
92+
name: pypi
93+
url: https://pypi.org/project/cloudfall/
94+
permissions:
95+
id-token: write
96+
steps:
97+
- uses: actions/download-artifact@v8
98+
with:
99+
name: dist
100+
path: dist/
101+
102+
- uses: pypa/gh-action-pypi-publish@v1.14.2

‎.github/workflows/release.yml‎

Lines changed: 61 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,69 @@
1-
---
21
name: Release
32

4-
"on":
3+
# shipmill: https://github.com/shipmill/shipmill. The policy in
4+
# .github/shipmill.toml decides what releases and when; this file only wires
5+
# shipmill's workflows to this repository's CI.
6+
7+
on:
58
push:
6-
tags:
7-
- "v*"
9+
branches: [main]
10+
schedule:
11+
- cron: "7 * * * *" # hourly: the policy decides whether a lane's window is open
12+
workflow_dispatch:
13+
inputs:
14+
lane:
15+
description: "policy: follow the policy; a lane: release it now, skipping triggers but not gates"
16+
type: choice
17+
options: [policy, dev, rc, stable, hotfix]
18+
default: policy
19+
dry-run:
20+
description: "Show the release commit, push nothing"
21+
type: boolean
22+
default: true
23+
hotfix-prs:
24+
description: "hotfix: merged pull requests to ship, such as 12,15"
25+
type: string
26+
default: ""
27+
hotfix-from:
28+
description: "hotfix: the stable tag to fix; empty takes the latest"
29+
type: string
30+
default: ""
831

9-
jobs:
10-
publish:
11-
runs-on: ubuntu-latest
32+
permissions:
33+
contents: read
1234

13-
# PyPI trusted publishing: the workflow proves its identity with a
14-
# short-lived OIDC token, so no API token is stored anywhere.
15-
environment: pypi
35+
jobs:
36+
prepare:
37+
uses: shipmill/shipmill/.github/workflows/prepare.yml@v0
38+
with:
39+
lane: ${{ inputs.lane != 'policy' && inputs.lane || '' }}
40+
dry-run: ${{ inputs.dry-run || false }}
41+
hotfix-prs: ${{ inputs.hotfix-prs || '' }}
42+
hotfix-from: ${{ inputs.hotfix-from || '' }}
1643
permissions:
17-
id-token: write
18-
19-
steps:
20-
- uses: actions/checkout@v7
21-
22-
- uses: astral-sh/setup-uv@v10.1.0
23-
with:
24-
python-version: "3.14"
25-
26-
- name: Install dependencies
27-
run: uv sync
28-
29-
- name: Check the tag matches the project version
30-
run: |
31-
version="$(uv run python -c 'import tomllib, pathlib; \
32-
print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
33-
tag="${GITHUB_REF_NAME#v}"
34-
if [ "${version}" != "${tag}" ]; then
35-
echo "tag ${GITHUB_REF_NAME} does not match project version ${version}" >&2
36-
exit 1
37-
fi
44+
contents: write # pushes the release commit's work branch
45+
issues: write # blockers and milestones; opens the issue a hold or release = "propose" leads to
46+
pull-requests: read # a hotfix's merge commits
47+
actions: read # replaces a work branch a cancelled run left, once no other run owns it
3848

39-
- name: Lint
40-
run: uv run ruff check .
49+
ci:
50+
needs: prepare
51+
if: needs.prepare.outputs.sha != ''
52+
uses: ./.github/workflows/ci.yml
53+
with:
54+
ref: ${{ needs.prepare.outputs.sha }}
4155

42-
- name: Type-check
43-
run: uv run mypy
44-
45-
- name: Test
46-
run: uv run pytest
47-
48-
- name: Build
49-
run: uv build
50-
51-
# The wheel carries the schema catalog and the Ansible engine as package
52-
# data; a project installed from PyPI has no checkout to fall back on, so
53-
# an empty bundle must fail the release rather than ship.
54-
- name: Check the wheel carries the bundled schemas and engine
55-
run: |
56-
uv run python - <<'PY'
57-
import sys
58-
import zipfile
59-
from pathlib import Path
60-
61-
wheel = next(Path("dist").glob("*.whl"))
62-
names = zipfile.ZipFile(wheel).namelist()
63-
for expected in ("cloudfall/_bundled/schemas/", "cloudfall_engine/_bundled/ansible/"):
64-
if not any(name.startswith(expected) for name in names):
65-
sys.exit(f"{wheel.name} carries nothing under {expected}")
66-
PY
67-
68-
# The published artifact, exercised the way a user gets it: installed
69-
# into an empty environment with no repository in sight.
70-
- name: Smoke-test the built wheel
71-
run: |
72-
uv venv /tmp/wheel-check
73-
VIRTUAL_ENV=/tmp/wheel-check uv pip install dist/*.whl
74-
/tmp/wheel-check/bin/cloudfall config validate --project config/examples
75-
/tmp/wheel-check/bin/cloudfall-engine inventory render --project config/examples
76-
77-
- name: Publish to PyPI
78-
run: uv publish --trusted-publishing always
56+
land:
57+
needs: [prepare, ci]
58+
if: always() && needs.prepare.outputs.sha != ''
59+
uses: shipmill/shipmill/.github/workflows/land.yml@v0
60+
with:
61+
ci-result: ${{ needs.ci.result }}
62+
lane: ${{ needs.prepare.outputs.lane }}
63+
version: ${{ needs.prepare.outputs.version }}
64+
sha: ${{ needs.prepare.outputs.sha }}
65+
base: ${{ needs.prepare.outputs.base }}
66+
permissions:
67+
contents: write # main, release branches, tags, and releases
68+
actions: write # starts the lane's dispatch workflows
69+
issues: write # closes the issue release = "propose" opens, once the lane released

0 commit comments

Comments
 (0)