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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Changed

- **Breaking for newly copied templates.** The `template/` release workflow
calls continuo's public release API instead of an unauthenticated webhook:
- `RELEASE_ENDPOINT` keeps its name but is now the origin of continuo's
`auth.publicUrl` (`scheme://host[:port]`, no path), which is also the OIDC
audience.
- The repository must be bound to the service in continuo's
`ciAuth.bindings`.
- The first release of a service is an operator bootstrap; the workflow never
sends `bootstrap`.

The workflow checks the endpoint and does an authenticated read of
`/api/v1/current-prod` before building, so a wrong URL, audience or missing
binding fails early. It then submits to `<RELEASE_ENDPOINT>/api/v1/releases`
with a fresh GitHub Actions OIDC token per call and polls
`GET /api/v1/releases/{id}` for up to about 50 minutes (`timeout-minutes: 60`),
failing on `rejected` and `superseded`. A newer push no longer cancels a run
that is waiting for its release (`cancel-in-progress: false`). Workflows
already copied from the template are untouched.

## [0.8.0] - 2026-10-01

Packages in this release: `continuo-python-runtime` 0.8.0 (no runtime code
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,13 @@ the Go parser has not been taught is a production outage, not a refactor.
service name (one service name per domain repo).
3. Configure repository variables in GitHub (Settings → Secrets and
variables → Actions): `REGISTRY` (your Docker registry), `BUCKET` (your
S3 bucket for contract artifacts), `RELEASE_ENDPOINT` (the release
webhook endpoint). `RELEASE_ENDPOINT` is the **base URL** of the Continuo
API (no `/releases` suffix) — the workflow appends `/releases` itself.
S3 bucket for contract artifacts), `RELEASE_ENDPOINT` (the base
URL of your continuo install, `scheme://host[:port]` with no path, the origin of continuo's
`auth.publicUrl`). The
workflow calls `<RELEASE_ENDPOINT>/api/v1/releases` with its GitHub Actions
OIDC token, so the repository must be bound to your service in continuo's
`ciAuth.bindings` (see [Releasing from CI](https://github.com/carolsimone/continuo/blob/main/deploy/README.md#releasing-from-ci-github-actions)),
and an operator bootstraps the service's first release.
4. Configure repository secrets: `AWS_ACCESS_KEY_ID` and
`AWS_SECRET_ACCESS_KEY` for the S3 upload. The template workflow pushes
the built image to GHCR using the workflow's own `GITHUB_TOKEN` (granted
Expand Down
26 changes: 17 additions & 9 deletions docs/boundary-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ s3://<bucket>/<service>/<release_id>/contract.yaml
- `output_columns` types come from the supported set: `BIGINT`,
`INT`/`INTEGER`, `DOUBLE PRECISION`, `NUMERIC(p,s)`/`DECIMAL(p,s)`,
`VARCHAR(n)`/`CHAR(n)`/`TEXT`, `TIMESTAMP`, `DATE`, `BOOLEAN`.
- Ordering is a hard rule: **upload completes before `POST /releases`** —
- Ordering is a hard rule: **upload completes before `POST /api/v1/releases`** —
Continuo does no existence check (D3); a POST racing its own upload
fails at the parsing stage.

Expand Down Expand Up @@ -285,21 +285,29 @@ own `import` statements, resolved by static AST analysis
## 13.3 Surface 3 — the release call

```
POST /releases
POST <continuo-origin>/api/v1/releases
Authorization: Bearer <GitHub Actions OIDC token>
{
"service": "marketing-py", # one service name per domain repo
"release_id": "<unique, matches the S3 key path>",
"image_tag": "<registry>/<image>:<tag>", # the image the executor will run
"repo": "owner/name", # where the source lives (remediation)
"commit_sha": "<full sha>", # must contain scripts + contracts
"kind": "python"
}
```

202 Accepted `{"release_id": …, "status": "received"}`; 400 on any missing
field. Idempotent on `release_id` — safe to retry. `repo` + `commit_sha`
must point at the actual source of the scripts and contract files, because
the remediation agent fetches them from GitHub to propose fix PRs.
The token's audience is the origin of the continuo install, and the
repository must be bound to the service in continuo's `ciAuth.bindings`
([Releasing from CI](https://github.com/carolsimone/continuo/blob/main/deploy/README.md#releasing-from-ci-github-actions)). A CI
token supplies `repo` and `commit_sha` (the repository and commit the workflow
runs in), so the body leaves them out; they must point at the actual source of
the scripts and contract files, because the remediation agent fetches them from
GitHub to propose fix PRs. 202 Accepted `{"release_id": …, "status": …}`; 400
on a missing or unknown field. Idempotent on `release_id` with the same body —
safe to retry. The workflow then polls `GET /api/v1/releases/{release_id}`
with a fresh token until `terminal` is true: `promoted` is success, `rejected`
and `superseded` are failures. The first release of a service is an operator
bootstrap (`"bootstrap": true`), which a CI binding may send only with
`allowBootstrap`.

## 13.4 Surface 4 — the runtime image

Expand Down Expand Up @@ -399,7 +407,7 @@ intentionally differ from the dbt job env (`SCHEMA`/`DBT_TARGET_SCHEMA`/dbt
2. merge contract files → contract.yaml; compute the per-node hash fields (§13.2)
3. build + push the image (scripts + contracts + harness baked in)
4. upload contract.yaml → s3://<bucket>/<service>/<release_id>/contract.yaml
5. POST /releases {…, kind: "python"} # only after 3 and 4 succeed
5. POST /api/v1/releases {…, kind: "python"} # only after 3 and 4 succeed; then poll to a terminal status
```

Provisioning Continuo hands each domain repo, once: S3 write credentials
Expand Down
140 changes: 131 additions & 9 deletions template/.github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,90 @@
name: release
on:
push: { branches: [main] }
# A run that is waiting for continuo's verdict must not be cancelled by a newer
# push: the release it submitted keeps going in continuo regardless, and the job
# would stop reporting its outcome. Runs queue instead, and continuo supersedes
# an older release when a newer one is promoted.
concurrency:
group: release
cancel-in-progress: true
cancel-in-progress: false
env:
SERVICE: your-service-name # one service name per domain repo
# Repository variable RELEASE_ENDPOINT: the origin of continuo's `auth.publicUrl`,
# exactly: lowercase host, no default port, no path (e.g. https://continuo.example.com).
# It is also the OIDC audience, which assumes continuo's default `ciAuth.audience`.
# This repository must be bound to $SERVICE in continuo's `ciAuth.bindings`.
RELEASE_ENDPOINT: ${{ vars.RELEASE_ENDPOINT }}
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 60
permissions: { contents: read, packages: write, id-token: write }
steps:
# Fail before building anything when the endpoint is missing or malformed.
# The whole value must match: one line, no whitespace, no credentials, no
# path. CONTINUO_ORIGIN (the endpoint without a trailing slash) is both the
# API base and the OIDC audience. The value is never echoed.
- name: Check release endpoint
run: |
if [ -z "$RELEASE_ENDPOINT" ]; then
echo "::error::repository variable RELEASE_ENDPOINT is not set; set it to continuo's base URL (scheme://host)"
exit 1
fi
origin_re='^https?://[^/?#@[:space:]]+/?$'
if ! [[ $RELEASE_ENDPOINT =~ $origin_re ]]; then
echo "::error::RELEASE_ENDPOINT must be continuo's base URL as scheme://host[:port]: one line, no path, no credentials"
exit 1
fi
echo "CONTINUO_ORIGIN=${RELEASE_ENDPOINT%/}" >> "$GITHUB_ENV"
# Fail before building anything when the URL, the token audience or the
# repository binding is wrong: an authenticated read of /api/v1/current-prod
# is refused (401 or 403) unless the repository is bound in `ciAuth.bindings`.
# Also writes the helpers the submit step reuses. Every call asks for a fresh
# OIDC token (they expire within minutes) and passes it to curl only as a
# header read from stdin, so it never reaches a log or a process listing.
- name: Preflight continuo API
run: |
cat > "$RUNNER_TEMP/continuo-api.sh" <<'HELPERS'
oidc_token() {
local aud
aud="$(jq -rn --arg a "$CONTINUO_ORIGIN" '$a | @uri')"
printf 'Authorization: bearer %s\n' "$ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
| curl -sS --fail -H @- "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${aud}" \
| jq -er '.value'
}

# call METHOD PATH [BODY]: prints the HTTP status (000 when curl or the
# token request failed); the response body is left in $resp.
call() {
local method="$1" path="$2" body="${3:-}" token out
token="$(oidc_token)" || { echo "could not request a GitHub Actions OIDC token" >&2; echo 000; return 0; }
local -a args=(-sS -X "$method" -o "$resp" -w '%{http_code}' -H @- -H 'Accept: application/json')
[ -z "$body" ] || args+=(-H 'Content-Type: application/json' -d "$body")
out="$(printf 'Authorization: Bearer %s\n' "$token" | curl "${args[@]}" "${CONTINUO_ORIGIN}${path}" || true)"
echo "${out:-000}"
}

fail_with_response() {
echo "::error::$1: HTTP $2 $(jq -r '"code=\(.code // "unknown") error=\(.error // "none")"' "$resp" 2>/dev/null || true)"
exit 1
}
HELPERS

resp="$(mktemp)"
trap 'rm -f "$resp"' EXIT
. "$RUNNER_TEMP/continuo-api.sh"
for attempt in 1 2 3; do
status="$(call GET /api/v1/current-prod)"
case "$status" in 000 | 429 | 502 | 503 | 504) [ "$attempt" -eq 3 ] || sleep $((attempt * 5)) ;; *) break ;; esac
done
case "$status" in
200) echo "continuo API reachable and this repository is bound" ;;
401 | 403)
echo "::error::continuo refused this repository (HTTP $status). Check that RELEASE_ENDPOINT is exactly the origin of continuo's auth.publicUrl (the OIDC audience), and that this repository's id is listed in continuo's ciAuth.bindings for the service."
exit 1 ;;
*) fail_with_response "reading $CONTINUO_ORIGIN/api/v1/current-prod" "$status" ;;
esac
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
# PRECONDITION: continuo-python-runtime must be published to PyPI (this repo's release pipeline).
Expand Down Expand Up @@ -53,14 +127,62 @@ jobs:
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: POST /releases # strictly after build+push and upload
# Strictly after build+push and upload: continuo does no existence check, so
# a release that races its own artifacts fails at the parsing stage.
#
# Submits the release to continuo's public API and waits for its verdict.
# Each call carries a fresh GitHub Actions OIDC token (see the preflight
# step) whose audience is the origin of RELEASE_ENDPOINT. The token carries
# `repo` and `commit_sha`, so the body leaves them out. Promoted is success;
# rejected and superseded fail the job.
- name: Submit release to continuo
run: |
body=$(jq -n \
--arg service "$SERVICE" \
resp="$(mktemp)"
trap 'rm -f "$resp"' EXIT
. "$RUNNER_TEMP/continuo-api.sh"

body="$(jq -n \
--arg release_id "$RELEASE_ID" \
--arg service "$SERVICE" \
--arg image_tag "$IMAGE_TAG" \
--arg repo "${{ github.repository }}" \
--arg commit_sha "$GITHUB_SHA" \
'{service: $service, release_id: $release_id, image_tag: $image_tag, repo: $repo, commit_sha: $commit_sha, kind: "python"}')
curl --fail-with-body -X POST "${{ vars.RELEASE_ENDPOINT }}/releases" \
-H 'Content-Type: application/json' -d "$body"
'{release_id: $release_id, service: $service, image_tag: $image_tag, kind: "python"}')"

# A submit is idempotent on release_id, so transient failures are retried.
for attempt in 1 2 3; do
status="$(call POST /api/v1/releases "$body")"
case "$status" in 000 | 429 | 502 | 503 | 504) [ "$attempt" -eq 3 ] || sleep $((attempt * 5)) ;; *) break ;; esac
done
[ "$status" = 202 ] || fail_with_response "submitting release $RELEASE_ID" "$status"
echo "submitted release $RELEASE_ID (service=$SERVICE) to $CONTINUO_ORIGIN"

# Poll to a terminal status: 300 polls x 10 s = 50 minutes.
last=""
for _ in $(seq 1 300); do
status="$(call GET "/api/v1/releases/$RELEASE_ID")"
case "$status" in
200) ;;
000 | 429 | 502 | 503 | 504) sleep 10; continue ;;
*) fail_with_response "reading release $RELEASE_ID" "$status" ;;
esac
state="$(jq -r '.status // empty' "$resp")"
if [ "$state" != "$last" ]; then echo "release $RELEASE_ID status: ${state:-unknown}"; last="$state"; fi
if [ "$(jq -r '.terminal // false' "$resp")" = true ]; then
ui_url="$(jq -r '.ui_url // empty' "$resp")"
[ -z "$ui_url" ] || echo "details: $ui_url"
case "$state" in
promoted) exit 0 ;;
rejected)
echo "::error::release $RELEASE_ID rejected: $(jq -r '"\(.reject_reason // "") \(.reject_detail // "")"' "$resp")"
exit 1 ;;
superseded)
echo "::error::release $RELEASE_ID was superseded by a newer release before it could be promoted"
exit 1 ;;
*)
echo "::error::release $RELEASE_ID ended with unexpected status '$state'"
exit 1 ;;
esac
fi
sleep 10
done
echo "::error::timed out waiting for release $RELEASE_ID to reach a terminal status (last: ${last:-unknown})"
exit 1
39 changes: 29 additions & 10 deletions template/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Continuo Python Domain Repository Template
# continuo Python Domain Repository Template

This is a copy-ready template for implementing a [Continuo Python domain repo](https://github.com/carolsimone/continuo-python-runtime).
This is a copy-ready template for implementing a [continuo Python domain repo](https://github.com/carolsimone/continuo-python-runtime).

## Quick Start

Expand All @@ -9,16 +9,32 @@ This is a copy-ready template for implementing a [Continuo Python domain repo](h
3. **Configure repository variables** in GitHub (Settings → Secrets and variables → Actions):
- `REGISTRY`: Your Docker registry (e.g., `ghcr.io/org`)
- `BUCKET`: Your S3 bucket for contract artifacts
- `RELEASE_ENDPOINT`: Your release webhook endpoint. This is the **base
URL** of the Continuo API (no `/releases` suffix) — the workflow
appends `/releases` itself.
- `RELEASE_ENDPOINT`: the base URL of your continuo install. It must be
exactly the origin of continuo's `auth.publicUrl`: lowercase host, no
default port, no path (for example `https://continuo.example.com`). The
workflow also uses it as the OIDC token audience, which assumes continuo's
default `ciAuth.audience`; if the install sets a different one, change the
`audience` the workflow requests. It calls
`<RELEASE_ENDPOINT>/api/v1/releases`, and fails before building anything
when the variable is empty or malformed, or when continuo refuses this
repository (a preflight read of `/api/v1/current-prod`).
4. **Configure repository secrets**:
- `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` for S3 uploads
- `release.yml` already logs in to `ghcr.io` with the built-in `GITHUB_TOKEN`
(no extra secret needed) — only add your own login step if `REGISTRY`
points at a registry other than `ghcr.io`
5. **Write your contracts** in `contracts/` and **implement scripts** in `scripts/`
6. **Push to main** to trigger the release pipeline
5. **Bind the repository in continuo.** The workflow authenticates with its
GitHub Actions OIDC token (`id-token: write`, already set), so no secret is
stored. The operator who runs the install lists this repository under
`ciAuth.bindings` for your service name; see
[Releasing from CI](https://github.com/carolsimone/continuo/blob/main/deploy/README.md#releasing-from-ci-github-actions). Until the
repository is bound, every release call is refused.
6. **Bootstrap the service once.** The first release of a service has no
production version to validate against, so an operator promotes it with
`"bootstrap": true`; the workflow never sends that flag. Releases from the
workflow work once the service has been bootstrapped.
7. **Write your contracts** in `contracts/` and **implement scripts** in `scripts/`
8. **Push to main** to trigger the release pipeline

## Choosing a base

Expand All @@ -29,7 +45,7 @@ need one Dockerfile in your repo.

**Shape 1 — `Dockerfile`, `FROM` the engine image (simplest).** Builds
`FROM ghcr.io/carolsimone/continuo-python-runtime-<engine>:vX.Y.Z`, an image
that already has the Continuo runtime and one engine adapter installed and
that already has the continuo runtime and one engine adapter installed and
pinned by the publisher. You only add your `contracts/` and `scripts/` (and
any extra dependency your script needs). Pin by tag or digest
(`:vX.Y.Z@sha256:<digest>`) for reproducibility. Use this unless you have a
Expand Down Expand Up @@ -74,9 +90,12 @@ The CI/CD pipeline (`release.yml`) performs the six-step orchestration:
3. **Run domain tests** (optional, if `tests/` exists)
4. **Merge** contracts into a single artifact
5. **Build and push** Docker image
6. **Upload contract** and **POST release notification**
6. **Upload contract**, then **submit the release** to continuo's
`POST /api/v1/releases` and poll `GET /api/v1/releases/{id}` for up to about
50 minutes (the job's `timeout-minutes` is 60). The job succeeds when the release is `promoted` and fails when it
is `rejected` or `superseded`

## Resources

- [Continuo Python Runtime Documentation](https://github.com/carolsimone/continuo-python-runtime)
- [continuo Python Runtime Documentation](https://github.com/carolsimone/continuo-python-runtime)
- [Boundary Contract (design §13)](https://github.com/carolsimone/continuo-python-runtime/blob/main/docs/boundary-contract.md)
Loading
Loading