From efe53309286eb69942684b2c2b126c9a543b6ee5 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 7 Oct 2026 18:00:47 +0300 Subject: [PATCH 1/3] ci: cut releases with shipmill from the CHANGELOG Releases move from hand-pushed tags to shipmill's lanes: rc after each batch of merges, stable promoted from an rc soaked 3 days once its milestone closes, hotfix by hand. The policy starts in dry-run. - ci.yml runs on workflow_call with a ref, so a release checks its own commit - release.yml (tag-triggered PyPI publish) becomes publish.yml, started by shipmill with a tag input; it reruns CI on the tag, builds, checks the bundle and the sdist, and smoke-tests both artifacts before uploading - release.yml is now shipmill's caller workflow - release headings use ' - ' before the date, the form shipmill parses - the sdist excludes .github, uv.lock, evaluations, site and build output - [agents] runs the gate headless as shipmill-romamo[bot], triage only Co-Authored-By: Claude Opus 5.5 --- .github/shipmill.toml | 95 ++++++++++++++++++++++++ .github/workflows/ci.yml | 8 +++ .github/workflows/publish.yml | 102 ++++++++++++++++++++++++++ .github/workflows/release.yml | 131 ++++++++++++++++------------------ CHANGELOG.md | 19 +++-- pyproject.toml | 14 ++++ 6 files changed, 292 insertions(+), 77 deletions(-) create mode 100644 .github/shipmill.toml create mode 100644 .github/workflows/publish.yml diff --git a/.github/shipmill.toml b/.github/shipmill.toml new file mode 100644 index 0000000..279041e --- /dev/null +++ b/.github/shipmill.toml @@ -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 = "/github-issue-triage {repo} triage the new issues and open a PR for each one to build; do not merge" +prs = false # 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39af053..e7e156c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..95ceca1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b033879..75e31ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a50298..e9bb404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,11 @@ Notable changes to Cloudfall. The format follows github.com/cloudfall-dev/cloudfall. The package metadata, the README, the site and `REPOSITORY_URL` point there; the old github.com/romamo/cloudfall address redirects +- Releases are cut by [shipmill](https://github.com/shipmill/shipmill) from + this CHANGELOG: an rc (`0.6.0rc1`, ...) after each batch of merges, and a + stable release promoted from an rc that soaked 3 days once its milestone + closes. The source distribution leaves out the repository's tooling, + evaluations and website - **Breaking:** the CLI follows one name per meaning, and its output is typed: - Decision records have their own group: `decisions list` (was @@ -336,7 +341,7 @@ Notable changes to Cloudfall. The format follows `failed` receipt, left the proposal `proposed` with no trace that it ran, and showed the caller a traceback -## [0.5.1] — 2026-09-22 +## [0.5.1] - 2026-09-22 ### Changed @@ -357,9 +362,9 @@ Notable changes to Cloudfall. The format follows sees them rather than by absolute path, because a record is committed and one machine's home directory means nothing in anyone else's checkout -## [0.5.0] — 2026-09-21 +## [0.5.0] - 2026-09-21 -## [0.4.0] — 2026-09-21 +## [0.4.0] - 2026-09-21 ### Added @@ -401,7 +406,7 @@ Notable changes to Cloudfall. The format follows mode and records a proposal, and no tool approves anything: that stays a command a person runs -## [0.3.0] — 2026-09-20 +## [0.3.0] - 2026-09-20 ### Removed @@ -420,7 +425,7 @@ Notable changes to Cloudfall. The format follows - The quickstart and the Render migration guide install Cloudfall from the index rather than from git -## [0.2.1] — 2026-09-20 +## [0.2.1] - 2026-09-20 ### Fixed @@ -444,7 +449,7 @@ Notable changes to Cloudfall. The format follows - `--rev` documents that it pins a commit from `--source`, which is now the explicit alternative to the default rather than the only mechanism -## [0.2.0] — 2026-09-20 +## [0.2.0] - 2026-09-20 ### Added @@ -543,7 +548,7 @@ Notable changes to Cloudfall. The format follows - `cloudfall init` suggests `uv run cloudfall config validate` as the next step; the previous `config validate .` hint no longer parsed -## [0.1.0] — 2026-09-08 +## [0.1.0] - 2026-09-08 First public milestone: the Render-to-Hetzner wedge proven live end to end on disposable Hetzner Cloud Debian 13 servers (see diff --git a/pyproject.toml b/pyproject.toml index 8f46c16..ed69b3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,20 @@ packages = ["sdk/src/cloudfall", "engine/src/cloudfall_engine"] # Installed wheels carry the schema catalog and the Ansible engine as package # data so a fleet repository needs nothing from this checkout; see # cloudfall.resources for how defaults resolve. +# The sdist carries what builds and tests the package, not the repo's tooling, +# evaluations or website. +[tool.hatch.build.targets.sdist] +exclude = [ + "/.github", + "/.agents", + "/.claude", + "/uv.lock", + "/evaluations", + "/site", + "/tmp", + "/dist", +] + [tool.hatch.build.targets.wheel.force-include] "config/schemas" = "cloudfall/_bundled/schemas" "engine/ansible" = "cloudfall_engine/_bundled/ansible" From fc1db9601e62c56c55474561864cb09fb1e78f41 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 7 Oct 2026 18:01:19 +0300 Subject: [PATCH 2/3] Enable the shipmill plugin for sessions in this repo Co-Authored-By: Claude Opus 5.5 --- .claude/settings.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3e86b9b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "extraKnownMarketplaces": { + "shipmill": { + "source": { + "source": "github", + "repo": "shipmill/shipmill" + }, + "autoUpdate": true + } + }, + "enabledPlugins": { + "shipmill@shipmill": true + } +} From a0cad9863b6ba577958721fde26ca2b8fa00ed8f Mon Sep 17 00:00:00 2001 From: Romamo Date: Thu, 8 Oct 2026 12:16:37 +0300 Subject: [PATCH 3/3] Let the gate land pull requests when CI is green Name the triage skill with the plugin's prefix, so a same-named skill in ~/.claude/skills can't answer in its place. Co-Authored-By: Claude Opus 5.5 --- .github/shipmill.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/shipmill.toml b/.github/shipmill.toml index 279041e..f5dd382 100644 --- a/.github/shipmill.toml +++ b/.github/shipmill.toml @@ -85,8 +85,8 @@ dispatch = ["publish.yml"] # shipmill gate (launchd) starts a Claude Code session only when the repo needs one [agents] -prompt = "/github-issue-triage {repo} triage the new issues and open a PR for each one to build; do not merge" -prs = false # true when the prompt lands PRs +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