Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"extraKnownMarketplaces": {
"shipmill": {
"source": {
"source": "github",
"repo": "shipmill/shipmill"
},
"autoUpdate": true
}
},
"enabledPlugins": {
"shipmill@shipmill": true
}
}
95 changes: 95 additions & 0 deletions .github/shipmill.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# shipmill reads this file on every run: https://github.com/shipmill/shipmill
name = "cloudfall"

# off: do nothing; dry-run: show the release commit it would make; release: release
mode = "dry-run"
branch = "main"

# pyproject: [project] version in pyproject.toml and uv.lock; none: tags and CHANGELOG only
version_files = "pyproject"

# Commands run after the version is written, before the commit; a failure stops the release
after_stamp = ["uv lock --check"]

[changelog]
path = "CHANGELOG.md"
style = "keep-a-changelog"

[bump]
from = "headings"
major = ["Breaking"]
minor = ["Added", "Changed", "Deprecated", "Removed"]
patch = ["Fixed", "Security"]

[gates]
# An open issue with this label holds the listed lanes
blocker_label = "release-blocker"
blocker_lanes = ["rc", "stable"]
# Inclusive UTC date ranges when the listed lanes hold, such as "2026-12-24..2027-01-02"
freeze = []
freeze_lanes = ["dev", "rc", "stable"]

# Each lane releases when one of its triggers is due and no gate holds it; every lane can
# also be started by hand. One run releases one lane, in the order hotfix, stable, rc, dev.

# [lanes.dev] # X.Y.Z.devN after every batch of merges
# quiet_minutes = 30
# dispatch = ["publish.yml"]

[lanes.rc] # X.Y.ZrcN from main, after each batch of merges
quiet_minutes = 30
github_release = true
dispatch = ["publish.yml"]

# X.Y.Z, promoted from an rc soaked 3 days, once the milestone titled with the version
# (0.6.0) has closed issues and none open: the milestone is the only trigger, so it gates
[lanes.stable]
promote_from = "rc"
min_soak_days = 3
milestone = true
github_release = true
dispatch = ["publish.yml"]

[lanes.hotfix] # X.Y.(Z+1) from release/X.Y with chosen PRs, by hand only
github_release = true
dispatch = ["publish.yml"]

# Environments a release deploys to. The workflow runs on workflow_dispatch with `tag` and
# `environment` inputs, and its job sets `environment: ${{ inputs.environment }}`, so
# GitHub records a deployment for each run. With from or health, run
# `uvx --from git+https://github.com/shipmill/shipmill@v0 shipmill init --operate`:
# its workflow checks health every 10 minutes and promotes after the bake.
# [environments.staging]
# lane = "rc" # deploy every release of this lane when it lands
# workflow = "deploy.yml"
# health = "https://staging.example.com/health"
#
# [environments.production]
# from = "staging" # promoted from staging once it baked there
# workflow = "deploy.yml"
# health = "https://example.com/health"
# bake_minutes = 60 # how long staging stays healthy before promotion

# Lines a release rewrites besides the version files; each pattern matches exactly once.
# The replacement can use {name}, {version}, {semver}, {minor} (X.Y), and {date}.
# [[version_lines]]
# file = "README.md"
# pattern = 'cloudfall v\d+\.\d+\.\d+'
# replace = "cloudfall v{version}"

# How far shipmill goes by itself: observe (report only), propose (open an issue saying what
# it would release; a person starts the lane), or act (the default). An open issue labelled
# shipmill-hold turns every act into propose until it is closed.
# [autonomy]
# release = "act"

# shipmill gate (launchd) starts a Claude Code session only when the repo needs one
[agents]
prompt = "/shipmill:github-issue-triage {repo} triage the new issues; merge when green"
prs = true # true when the prompt lands PRs
retry_hours = 24 # unchanged findings start a new session after this
notify = true # a desktop notification when a session waits on you
remind_hours = 4 # repeat it while the session still waits
max_wait_minutes = 15 # stop a session that waited this long; 0: never
app_id = 5209412 # gated sessions write as shipmill-romamo[bot]
mode = "headless" # nobody attaches: sessions ask on GitHub with needs-decision
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,20 @@ name: CI
branches:
- main
pull_request:
# shipmill's release workflow and publish.yml run these checks on the commit they release
workflow_call:
inputs:
ref:
type: string
required: true

jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}

- uses: astral-sh/setup-uv@v10.1.0
with:
Expand Down
102 changes: 102 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
---
name: Publish

# shipmill's release workflow starts this on each tag it pushes (.github/shipmill.toml)
"on":
workflow_dispatch:
inputs:
tag:
description: Existing tag to publish, e.g. v0.6.0
required: true

permissions:
contents: read

concurrency:
group: publish-${{ inputs.tag }}
cancel-in-progress: false

env:
TAG: ${{ inputs.tag }}

jobs:
ci:
uses: ./.github/workflows/ci.yml
with:
ref: refs/tags/${{ inputs.tag }}

build:
needs: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: refs/tags/${{ env.TAG }}
persist-credentials: false

- uses: astral-sh/setup-uv@v10.1.0
with:
python-version: "3.14"

- name: Check the tag matches the project version
run: test "v$(uv version --short)" = "$TAG"

- run: uv build --out-dir dist

- name: Check the sdist leaves out tooling
run: |
if tar -tzf dist/*.tar.gz | grep -E '^[^/]+/(\.github|\.agents?|\.claude|uv\.lock)(/|$)'; then
exit 1
fi

# The wheel carries the schema catalog and the Ansible engine as package
# data; a project installed from PyPI has no checkout to fall back on, so
# an empty bundle must fail the release rather than ship.
- name: Check the wheel carries the bundled schemas and engine
run: |
uv run --no-project python - <<'PY'
import sys
import zipfile
from pathlib import Path

wheel = next(Path("dist").glob("*.whl"))
names = zipfile.ZipFile(wheel).namelist()
for expected in ("cloudfall/_bundled/schemas/", "cloudfall_engine/_bundled/ansible/"):
if not any(name.startswith(expected) for name in names):
sys.exit(f"{wheel.name} carries nothing under {expected}")
PY

# The published artifacts, exercised the way a user gets them: installed
# into an empty environment with no repository in sight.
- name: Smoke-test the wheel and the sdist
run: |
for dist in dist/*.whl dist/*.tar.gz; do
uv run --isolated --no-project --python 3.14 --with "$dist" \
cloudfall config validate --project config/examples
uv run --isolated --no-project --python 3.14 --with "$dist" \
cloudfall-engine inventory render --project config/examples
done

- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
if-no-files-found: error

# PyPI trusted publishing: the workflow proves its identity with a
# short-lived OIDC token, so no API token is stored anywhere.
publish:
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/cloudfall/
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
name: dist
path: dist/

- uses: pypa/gh-action-pypi-publish@v1.14.2
131 changes: 61 additions & 70 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,78 +1,69 @@
---
name: Release

"on":
# shipmill: https://github.com/shipmill/shipmill. The policy in
# .github/shipmill.toml decides what releases and when; this file only wires
# shipmill's workflows to this repository's CI.

on:
push:
tags:
- "v*"
branches: [main]
schedule:
- cron: "7 * * * *" # hourly: the policy decides whether a lane's window is open
workflow_dispatch:
inputs:
lane:
description: "policy: follow the policy; a lane: release it now, skipping triggers but not gates"
type: choice
options: [policy, dev, rc, stable, hotfix]
default: policy
dry-run:
description: "Show the release commit, push nothing"
type: boolean
default: true
hotfix-prs:
description: "hotfix: merged pull requests to ship, such as 12,15"
type: string
default: ""
hotfix-from:
description: "hotfix: the stable tag to fix; empty takes the latest"
type: string
default: ""

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read

# PyPI trusted publishing: the workflow proves its identity with a
# short-lived OIDC token, so no API token is stored anywhere.
environment: pypi
jobs:
prepare:
uses: shipmill/shipmill/.github/workflows/prepare.yml@v0
with:
lane: ${{ inputs.lane != 'policy' && inputs.lane || '' }}
dry-run: ${{ inputs.dry-run || false }}
hotfix-prs: ${{ inputs.hotfix-prs || '' }}
hotfix-from: ${{ inputs.hotfix-from || '' }}
permissions:
id-token: write

steps:
- uses: actions/checkout@v7

- uses: astral-sh/setup-uv@v10.1.0
with:
python-version: "3.14"

- name: Install dependencies
run: uv sync

- name: Check the tag matches the project version
run: |
version="$(uv run python -c 'import tomllib, pathlib; \
print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
tag="${GITHUB_REF_NAME#v}"
if [ "${version}" != "${tag}" ]; then
echo "tag ${GITHUB_REF_NAME} does not match project version ${version}" >&2
exit 1
fi
contents: write # pushes the release commit's work branch
issues: write # blockers and milestones; opens the issue a hold or release = "propose" leads to
pull-requests: read # a hotfix's merge commits
actions: read # replaces a work branch a cancelled run left, once no other run owns it

- name: Lint
run: uv run ruff check .
ci:
needs: prepare
if: needs.prepare.outputs.sha != ''
uses: ./.github/workflows/ci.yml
with:
ref: ${{ needs.prepare.outputs.sha }}

- name: Type-check
run: uv run mypy

- name: Test
run: uv run pytest

- name: Build
run: uv build

# The wheel carries the schema catalog and the Ansible engine as package
# data; a project installed from PyPI has no checkout to fall back on, so
# an empty bundle must fail the release rather than ship.
- name: Check the wheel carries the bundled schemas and engine
run: |
uv run python - <<'PY'
import sys
import zipfile
from pathlib import Path

wheel = next(Path("dist").glob("*.whl"))
names = zipfile.ZipFile(wheel).namelist()
for expected in ("cloudfall/_bundled/schemas/", "cloudfall_engine/_bundled/ansible/"):
if not any(name.startswith(expected) for name in names):
sys.exit(f"{wheel.name} carries nothing under {expected}")
PY

# The published artifact, exercised the way a user gets it: installed
# into an empty environment with no repository in sight.
- name: Smoke-test the built wheel
run: |
uv venv /tmp/wheel-check
VIRTUAL_ENV=/tmp/wheel-check uv pip install dist/*.whl
/tmp/wheel-check/bin/cloudfall config validate --project config/examples
/tmp/wheel-check/bin/cloudfall-engine inventory render --project config/examples

- name: Publish to PyPI
run: uv publish --trusted-publishing always
land:
needs: [prepare, ci]
if: always() && needs.prepare.outputs.sha != ''
uses: shipmill/shipmill/.github/workflows/land.yml@v0
with:
ci-result: ${{ needs.ci.result }}
lane: ${{ needs.prepare.outputs.lane }}
version: ${{ needs.prepare.outputs.version }}
sha: ${{ needs.prepare.outputs.sha }}
base: ${{ needs.prepare.outputs.base }}
permissions:
contents: write # main, release branches, tags, and releases
actions: write # starts the lane's dispatch workflows
issues: write # closes the issue release = "propose" opens, once the lane released
Loading
Loading