From a443730db8041aced263a93ce3d222a3750616d1 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Fri, 21 Aug 2026 14:45:45 +0200 Subject: [PATCH 1/4] ci: create a GitHub Release per version tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors continuo's release.yml, stripped to what actually applies here: no Helm chart, no Kubernetes deploy dir, no self-hosted runner or kind cluster in this repo, so none of retag-images/install-test/publish-chart/ smoke-published-chart exist. publish-pypi.yml and images.yml already do the real publishing work independently, each on their own push:tags trigger — this workflow's only job is to create the GitHub Release once both have succeeded for the tagged commit, since a tag also ships two ghcr.io images that PyPI's own release list knows nothing about. Triggered by the same tag push as its two siblings rather than by workflow_run watching them complete, since `needs:` cannot cross workflow files and this avoids relying on workflow_run's head_branch semantics for a tag-triggered source run. Instead it polls the Actions API for the sibling runs at this commit (event=push, to not pick up one of images.yml's pull_request runs) until both report a conclusion, exits non-zero on any non-success conclusion, and times out after 30 minutes per sibling. -test tags are excluded (TestPyPI dry run, no public Release). Release creation itself is idempotent (create-or-edit), matching continuo's approach; no CHANGELOG.md exists in this repo so notes are always --generate-notes. Verified: YAML validates (python3 -c "import yaml; yaml.safe_load(...)"). NOT verified: the actual cross-workflow wait/poll behavior, which needs a real tag push to exercise — there is no way to integration-test this locally or in a PR. Signed-off-by: Simone Carolini --- .github/workflows/release.yml | 106 ++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..118a2cb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,106 @@ +name: release + +# Creates a GitHub Release once BOTH tag-triggered publish workflows +# (publish-pypi.yml, images.yml) have finished successfully for this exact +# tag. A Release exists here beyond what PyPI already shows because a tag +# also ships two ghcr.io images that PyPI knows nothing about — the Release +# is the one place that says "this tag = these packages + these images." +# +# Triggered by the same tag push as its two siblings, rather than by +# workflow_run watching them complete: `needs:` cannot cross workflow files, +# and this avoids depending on workflow_run's head_branch semantics for a +# tag-triggered source run, which nothing else in this repo relies on. +# Instead the job below polls the Actions API for the sibling runs at this +# exact commit until both report a conclusion — that only depends on the +# same push:tags trigger every other release workflow here already uses. +on: + push: + tags: + # Digit after v: keeps this to release tags, same reasoning continuo's + # release.yml uses. publish-pypi.yml and images.yml use the looser `v*` + # and instead guard internally with startsWith(..., 'v') checks, so + # this is deliberately the stricter of the three triggers. + - "v[0-9]*" + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + github-release: + # -test tags are a TestPyPI dry run (see publish-pypi.yml) and skip + # images.yml's publish job by design (its own if: guards on this same + # condition) — no public Release for them. + if: "!contains(github.ref_name, '-test')" + runs-on: ubuntu-latest + # Generous relative to the two siblings' own timeouts (publish-pypi has + # none set; images.yml's build/smoke/publish jobs run unbounded too) — + # this job mostly just waits on them, so its ceiling has to clear + # whatever theirs turns out to be in practice, not just the polling math + # below. + timeout-minutes: 40 + permissions: + contents: write # gh release create/edit + actions: read # poll sibling workflow runs + env: + TAG: ${{ github.ref_name }} + SHA: ${{ github.sha }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Wait for publish-pypi.yml and images.yml to finish on this commit + run: | + set -euo pipefail + + wait_for() { + local workflow_file="$1" elapsed=0 interval=20 timeout=1800 + while :; do + # event=push, not just head_sha: images.yml also triggers on + # pull_request, and a coincidentally-identical head_sha between + # an open PR and this tag would otherwise pick up the wrong run. + conclusion="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow_file}/runs" \ + -f head_sha="${SHA}" -f event=push \ + --jq '.workflow_runs[0].conclusion // "pending"')" + case "${conclusion}" in + success) + echo "${workflow_file}: success" + return 0 + ;; + pending) + # Covers both "no matching run yet" (API eventual + # consistency right after the push) and "run exists but + # still queued/in_progress" — conclusion is JSON null in + # both cases until the run completes. + ;; + *) + echo "::error::${workflow_file} concluded '${conclusion}' for ${SHA}; not creating a Release." + exit 1 + ;; + esac + elapsed=$((elapsed + interval)) + if [ "${elapsed}" -ge "${timeout}" ]; then + echo "::error::timed out after ${timeout}s waiting for ${workflow_file} to finish on ${SHA}." + exit 1 + fi + sleep "${interval}" + done + } + + wait_for "publish-pypi.yml" + wait_for "images.yml" + + - name: Create the Release + run: | + set -euo pipefail + # No CHANGELOG.md exists in this repo (unlike continuo's + # deploy/continuo/CHANGELOG.md), so generated notes are the only + # source available. Idempotent: re-running this workflow for an + # existing tag updates the Release rather than failing. + if gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" --latest + else + gh release create "${TAG}" --repo "${GITHUB_REPOSITORY}" \ + --title "${TAG}" --generate-notes --latest + fi From 939d389d655e2fcae939a611faa412da70204339 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Fri, 21 Aug 2026 14:48:13 +0200 Subject: [PATCH 2/4] fix: gh api needs GET params in the URL, not -f, or it 404s and aborts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -f/-F on `gh api` switches the request to POST unless overridden — this listing endpoint only accepts GET, so the poll would 404 on its very first call under `set -e` and abort the job every time, regardless of whether publish-pypi.yml/images.yml actually succeeded. Verified against this repo's real API: the -f form 404s, the URL-query-string form returns real run data (tested against main's actual security.yml/ci.yml runs, both "success"). Also raised the job's timeout-minutes from 40 to 70: the two sequential wait_for calls can themselves take up to 30 minutes each — 60 minutes worst case on the polling math alone, before the siblings' own run time — so 40 could never actually cover it. Signed-off-by: Simone Carolini --- .github/workflows/release.yml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 118a2cb..be7ca45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,12 +36,11 @@ jobs: # condition) — no public Release for them. if: "!contains(github.ref_name, '-test')" runs-on: ubuntu-latest - # Generous relative to the two siblings' own timeouts (publish-pypi has - # none set; images.yml's build/smoke/publish jobs run unbounded too) — - # this job mostly just waits on them, so its ceiling has to clear - # whatever theirs turns out to be in practice, not just the polling math - # below. - timeout-minutes: 40 + # Each wait_for call below can itself run up to 30 minutes (its own + # internal timeout), called twice, sequentially — so this job's ceiling + # has to clear 60 minutes on its own polling math alone, before even + # accounting for how long the siblings actually take to run. + timeout-minutes: 70 permissions: contents: write # gh release create/edit actions: read # poll sibling workflow runs @@ -60,8 +59,13 @@ jobs: # event=push, not just head_sha: images.yml also triggers on # pull_request, and a coincidentally-identical head_sha between # an open PR and this tag would otherwise pick up the wrong run. - conclusion="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow_file}/runs" \ - -f head_sha="${SHA}" -f event=push \ + # Query params go in the URL, not via -f: gh api switches a + # request to POST the moment any -f/-F flag is present, and this + # listing endpoint only accepts GET — it 404s on POST, which + # under `set -e` would abort this script on the very first poll, + # every time. Verified against this repo's real API: -f flags + # 404 here, the URL query string form returns real run data. + conclusion="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow_file}/runs?head_sha=${SHA}&event=push" \ --jq '.workflow_runs[0].conclusion // "pending"')" case "${conclusion}" in success) From 5f8c15406534dce3b0af0c758795b89aefe02203 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Fri, 21 Aug 2026 15:00:24 +0200 Subject: [PATCH 3/4] feat: add CHANGELOG.md and wire release.yml to use it for Release notes Backfilled from actual tag history (0.1.0 through 0.3.0, plus what's pending for 0.3.1), Keep a Changelog style. release.yml now extracts the section matching the pushed tag's version and uses it as the Release body, falling back to GitHub's generated notes if a tag has no matching section, instead of always using generated notes. Verified the extraction awk script against the real file for 0.3.1/0.3.0/0.2.1 (all extract correctly) and a nonexistent version (correctly falls through to the no-match branch). CONTRIBUTING.md now says where release notes come from, so the file doesn't silently go stale. Signed-off-by: Simone Carolini --- .github/workflows/release.yml | 43 +++++++++++++++--- CHANGELOG.md | 84 +++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 6 +++ 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be7ca45..6938403 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,16 +95,47 @@ jobs: wait_for "publish-pypi.yml" wait_for "images.yml" + - name: Extract this version's CHANGELOG section + id: notes + run: | + set -euo pipefail + VERSION="${TAG#v}" + # Print the body between "## [X.Y.Z]" and the next "## [" heading. + if grep -qE "^## \[${VERSION}\]" CHANGELOG.md; then + awk -v ver="## [${VERSION}]" ' + index($0, ver) == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md > /tmp/release-body.md + fi + + if [ -s /tmp/release-body.md ]; then + echo "generate=false" >> "$GITHUB_OUTPUT" + else + # A tag with no matching CHANGELOG.md section (e.g. someone + # forgot to add one before tagging) still gets a Release — just + # with GitHub's generated notes instead of failing the job over + # a missing doc update. + echo "::warning::no '## [${VERSION}]' section in CHANGELOG.md; falling back to generated notes." + echo "generate=true" >> "$GITHUB_OUTPUT" + fi + - name: Create the Release run: | set -euo pipefail - # No CHANGELOG.md exists in this repo (unlike continuo's - # deploy/continuo/CHANGELOG.md), so generated notes are the only - # source available. Idempotent: re-running this workflow for an - # existing tag updates the Release rather than failing. + # Idempotent: re-running this workflow for an existing tag updates + # the Release rather than failing. if gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then - gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" --latest - else + if [ "${{ steps.notes.outputs.generate }}" = "true" ]; then + gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" --latest + else + gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" \ + --notes-file /tmp/release-body.md --latest + fi + elif [ "${{ steps.notes.outputs.generate }}" = "true" ]; then gh release create "${TAG}" --repo "${GITHUB_REPOSITORY}" \ --title "${TAG}" --generate-notes --latest + else + gh release create "${TAG}" --repo "${GITHUB_REPOSITORY}" \ + --title "${TAG}" --notes-file /tmp/release-body.md --latest fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a31289e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,84 @@ +# Changelog + +All notable changes to this project are documented here. Format loosely +follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +## [0.3.1] - 2026-08-21 + +### Added + +- Apache License 2.0, `CODE_OF_CONDUCT.md`, `DCO` with CI-enforced sign-off, + `CONTRIBUTING.md`, `SECURITY.md`, and a gitleaks + Trivy security-scanning + pipeline, ahead of open-sourcing this repository. +- `.github/workflows/release.yml`: creates a GitHub Release for a version tag + once that tag's PyPI publish and both engine images finish successfully — + the one place documenting "this tag = these packages + these images", + since PyPI's release history says nothing about the ghcr.io images. + +### Changed + +- `continuo-engine-contract` 0.7.0 → 0.7.1: the published wheel now embeds + `LICENSE`/`NOTICE` and declares license metadata, which the 0.7.0 wheel + omitted. Every exact pin on it — root `pyproject.toml` and both adapters' + — updated to match. + +## [0.3.0] - 2026-08-20 + +### Added + +- `continuo-engine-contract` is now vendored in this repository as a uv + workspace member (renamed from `continuo-validation-contract`), replacing + the external PyPI dependency of the same content. +- Ported the `validation-op` CLI path and its test suite in from + continuo-validation. + +### Removed + +- The external `continuo-validation-contract` PyPI dependency, and every + `continuo_validation_contract` reference across the codebase. + +## [0.2.1] - 2026-08-10 + +### Changed + +- Contract pin bumped to 0.6.0; `ensure_table` aligned with the port's + `config` parameter. + +### Fixed + +- Swept the remaining `contract==0.4.0` pin sites; added a guard against + future pin drift. + +### Added + +- CI publishes the runtime base images for both amd64 and arm64. + +## [0.2.0] - 2026-08-08 + +### Added + +- Three-part content hash, replacing the earlier single-hash formula. +- Physical-layout `config` on the node contract (partitioning, sort order, + format). +- A static in-repo import-closure resolver, and a lint rule rejecting + dynamic-import constructs. + +### Changed + +- Adopted continuo-validation-contract 0.4.0's type grammar and read gate. + +### Fixed + +- Closure resolver correctness and `sys.path` handling: index-name + uniqueness under truncation, cyclic `config` detection, UTF-8-BOM + decoding, unconditional `sys.path` repositioning. + +## [0.1.0] - 2026-08-03 + +### Added + +- Initial release: the runtime harness (`conform()`, `RunContext`, the + closure resolver), the `continuo-runtime` CLI, and the Postgres/Trino + engine adapters. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0494873..8c43557 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,6 +76,12 @@ scripts/security-scan.sh ## Conventions +- **Changelog.** A pull request whose changes are worth a release note adds an entry + under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), Keep a Changelog style. At + release time that section is renamed to the new version and a fresh empty + `## [Unreleased]` goes above it — `.github/workflows/release.yml` reads the section + matching the pushed tag to build the GitHub Release notes, and falls back to + GitHub's generated notes if none exists. - **Python logging.** Use the standard `logging` module for diagnostic output, never `print`. The only exception is machine-parsed stdout protocols (e.g. the CLI's sentinel-framed result blocks) — those stay as explicit `print`, since stdout is From d4722db9dffec680638550fc420681f57366bf19 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Fri, 21 Aug 2026 15:03:12 +0200 Subject: [PATCH 4/4] fix: exempt CHANGELOG.md from the legacy-validation-names guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci failed: CHANGELOG.md's 0.3.0 entry names continuo-validation-contract, the package that release actually replaced — the same reason docs/superpowers/'s dated design records are already exempt from this sweep (test_no_legacy_names.py's own words: rewriting them "would falsify the design history"). Extended the existing EXEMPT_PREFIXES mechanism rather than reword the changelog entry to dance around the name of what it's describing. Signed-off-by: Simone Carolini --- tests/test_no_legacy_names.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_no_legacy_names.py b/tests/test_no_legacy_names.py index 9291c4c..9919f6b 100644 --- a/tests/test_no_legacy_names.py +++ b/tests/test_no_legacy_names.py @@ -25,7 +25,10 @@ # plan and spec, and the 2026-08-07 config-hash plan). They describe what was # true on the day they were written; rewriting them to today's names would # falsify the design history, so they are exempt from the sweep. -EXEMPT_PREFIXES = ("docs/superpowers/",) +# +# CHANGELOG.md is the same category: its 0.3.0 entry names the package this +# rename replaced, because that is what actually shipped in that release. +EXEMPT_PREFIXES = ("docs/superpowers/", "CHANGELOG.md") def test_no_legacy_validation_names_anywhere():