From ea6db3e7b58c398176116d629564345afa44b6db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 14:02:56 +0000 Subject: [PATCH] chore(release): add v1.0.0 GitHub release tooling and notes Add tag-driven release workflow, local artifact builder (source archives, JAR, frontend tarball, MCP pack, CycloneDX SBOMs, checksums), changelog, and E2E verification record after full-stack redeploy smoke. Co-authored-by: Venkat SF --- .github/workflows/release.yml | 78 ++++++++++ .gitignore | 2 + CHANGELOG.md | 34 +++++ docs/oss-ux/E2E_RELEASE_VERIFICATION.md | 31 ++++ docs/oss-ux/README.md | 5 + docs/oss-ux/RELEASE.md | 93 ++++++++++++ docs/releases/RELEASE_NOTES-v1.0.0.md | 56 +++++++ scripts/release/build-artifacts.sh | 186 ++++++++++++++++++++++++ 8 files changed, 485 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 docs/oss-ux/E2E_RELEASE_VERIFICATION.md create mode 100644 docs/oss-ux/RELEASE.md create mode 100644 docs/releases/RELEASE_NOTES-v1.0.0.md create mode 100755 scripts/release/build-artifacts.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..334dd80 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: release + +# Cut a GitHub Release when a version tag is pushed. +# Artifacts match scripts/release/build-artifacts.sh (source, JAR, frontend, +# MCP pack, SBOMs, checksums, manifest). + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-and-publish: + name: build artifacts + GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v5.6.0 + with: + distribution: temurin + java-version: '25' + cache: maven + + - uses: actions/setup-node@v7 + with: + node-version: '22' + cache: npm + + - name: Build release artifacts + run: | + chmod +x scripts/release/build-artifacts.sh + ./scripts/release/build-artifacts.sh "${GITHUB_REF_NAME}" + + - name: Verify checksums + run: | + cd "release-artifacts/${GITHUB_REF_NAME}" + sha256sum -c SHA256SUMS + sha512sum -c SHA512SUMS + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: deepsql-${{ github.ref_name }} + path: release-artifacts/${{ github.ref_name }}/ + if-no-files-found: error + retention-days: 30 + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + NOTES="docs/releases/RELEASE_NOTES-${TAG}.md" + if [[ ! -f "$NOTES" ]]; then + NOTES="CHANGELOG.md" + fi + # Idempotent: if the release already exists (re-run), upload assets only. + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" "release-artifacts/${TAG}"/* --clobber + else + gh release create "$TAG" \ + --title "DeepSQL ${TAG}" \ + --notes-file "$NOTES" \ + --verify-tag \ + "release-artifacts/${TAG}"/* + fi diff --git a/.gitignore b/.gitignore index 46a2080..33c4e76 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ # production /build +# Local / CI release bundles (JAR, source archives, SBOMs, checksums) +/release-artifacts/ # misc .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..85d0d47 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to DeepSQL are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +for product tags (`vMAJOR.MINOR.PATCH`). + +## [1.0.0] — 2026-08-13 + +First public OSS release. + +### Added + +- Self-hosted DeepSQL stack: Spring Boot backend, React UI, DeepSQL Agent, MCP/CLI (`@deepsql/mcp`). +- Brain initialization with multi-schema discovery, coverage gates, and terminal `NEEDS_ATTENTION` handling. +- Agent tab SSO bridge with per-user MCP tokens, token-file rotation, and fail-loud provisioning. +- Dashboard artifact generation (HTML + sandboxed `deepsql.query` bridge). +- BYO LLM via OpenAI-compatible providers (`LlmProviderRegistry` / `LlmConfigResolver`). +- Official release tooling: `scripts/release/build-artifacts.sh` and tag-driven GitHub Release workflow. + +### Security + +- Session kill endpoints reject non-integer PIDs (SQL injection class closed). +- Dangerous controllers enforce connection ACL; Hermes/agent provisioner bind loopback. +- Compose Valkey password + Actuator limited to health for anonymous callers. +- JWT / session auth fail-closed when secrets are missing. + +### Known limitations + +- Residual high-severity items tracked in `docs/oss-ux/OSS_SECURITY_REVIEW.md` (IDOR sweep, SET preamble allowlist, SSRF hardening, share-password defaults) are deferred past this cut. +- Primary distribution path remains `docker compose up --build` (no pre-built container registry in this release). + +[1.0.0]: https://github.com/DeepSQLAI/deepsql/releases/tag/v1.0.0 diff --git a/docs/oss-ux/E2E_RELEASE_VERIFICATION.md b/docs/oss-ux/E2E_RELEASE_VERIFICATION.md new file mode 100644 index 0000000..261bfee --- /dev/null +++ b/docs/oss-ux/E2E_RELEASE_VERIFICATION.md @@ -0,0 +1,31 @@ +# E2E release verification — 2026-08-13 + +Stack: native Cloud VM (Postgres 16 + Redis + Spring Boot `:8080` + Vite `:3000` + Hermes `:8787` + provisioner `:8788`). `main` at `7833f31` (post security PR #49). Redeployed backend from packaged `dba-agent-backend-1.0.0.jar`. + +## Results + +| Check | Result | Notes | +|-------|--------|-------| +| `GET /api/actuator/health` | PASS | `200` | +| Anonymous Prometheus | PASS | `401` (not exposed) | +| Login `admin@localhost` | PASS | `auth_token` cookie | +| Connections list | PASS | 3 connections incl. `demo_shop` | +| Brain init status | PASS | `COMPLETED` / 100% | +| `/onboarding` | PASS | `200`, title DeepSQL | +| Agent session `mcpAuthOk` | PASS | `true` after provisioner restart | +| Agent Q&A (`e2e-agent-check.py`) | PASS | `execute_sql` → `demo_shop` | +| Dashboard generate | PASS | HTML + `deepsql.query` | +| Kill malicious PID | PASS | `400` Invalid session id | +| Logout | PASS | subsequent `/auth/me` → `401` | +| MCP JS syntax | PASS | `node --check` clean | +| Hermes bind | PASS | `127.0.0.1:8787` | + +## Ops notes discovered during redeploy + +1. **Provisioner must run current tree** — an Aug-12 `local-agent-provisioner.py` process lacked `DEEPSQL_TOKEN_FILE` writes; restarting from `scripts/local-agent-provisioner.py` fixed profile token files. +2. **Hermes webui caches MCP env** — after rotating tokens / updating `~/.hermes/config.yaml`, restart `hermes-webui` or Agent tab MCP calls 401 with stale env (USER_ID=`deepsql-agent`, no `DEEPSQL_AUTH_TOKEN`). Dashboard generate stayed green because it uses the channel-token path. +3. Syncing default `~/.hermes/config.yaml` `mcp_servers.deepsql.env` from the active `u-admin` profile after provision keeps native/dev Agent tab healthy. + +## Release gate + +Functional smoke + agent/dashboard E2E are green for the `v1.0.0` artifact cut. Remaining security Highs in `OSS_SECURITY_REVIEW.md` are documented, not blockers for this tagged OSS release. diff --git a/docs/oss-ux/README.md b/docs/oss-ux/README.md index b301b27..c565966 100644 --- a/docs/oss-ux/README.md +++ b/docs/oss-ux/README.md @@ -7,5 +7,10 @@ These docs are the durable home for OSS go-live planning. **Do not rely on `/opt | [`OSS_LAUNCH_USABILITY_CRITIQUE.md`](./OSS_LAUNCH_USABILITY_CRITIQUE.md) | E2E usability findings (Brain, Agent, onboarding) | | [`E2E_FIX_PROPOSAL.md`](./E2E_FIX_PROPOSAL.md) | Product fix workstreams W1–W7 + PR order | | [`OSS_SECURITY_REVIEW.md`](./OSS_SECURITY_REVIEW.md) | Security Criticals/Highs + S1–S10 track | +| [`E2E_RELEASE_VERIFICATION.md`](./E2E_RELEASE_VERIFICATION.md) | Pre-`v1.0.0` stack redeploy + smoke matrix results | +| [`RELEASE.md`](./RELEASE.md) | How to cut GitHub Releases (artifacts, tags, checksums) | +| [`DAILY_RELEASE_AUTOMATION.md`](./DAILY_RELEASE_AUTOMATION.md) | Morning PR triage automation prompt | + +Product release notes live under [`docs/releases/`](../releases/). Build locally with `./scripts/release/build-artifacts.sh v1.0.0` (output: `release-artifacts/v1.0.0/`). Other Cloud Agents: read these paths from the repo (this branch or after merge to `main`). diff --git a/docs/oss-ux/RELEASE.md b/docs/oss-ux/RELEASE.md new file mode 100644 index 0000000..62fe20d --- /dev/null +++ b/docs/oss-ux/RELEASE.md @@ -0,0 +1,93 @@ +# DeepSQL release process + +How we cut an official GitHub release with builds, source archives, checksums, and SBOMs. + +## Versioning + +| Surface | Where | Current | +|---------|-------|---------| +| Product / Git tag | `vMAJOR.MINOR.PATCH` | `v1.0.0` | +| Backend JAR | `backend/pom.xml` `` | `1.0.0` | +| Frontend package | root `package.json` | `0.1.0` (internal) | +| MCP / CLI npm | `mcp/package.json` | `0.27.0` | + +Tag the **product** version (`v1.0.0`). Keep backend `pom.xml` in lockstep with the tag for the JAR filename. MCP may continue its own semver when publishing `@deepsql/mcp` to npm. + +## Pre-flight (release manager) + +1. `main` is green on CI (`frontend`, `backend`, `mcp`, `compose-build`). +2. Smoke matrix from `docs/oss-ux/E2E_FIX_PROPOSAL.md` W7 passes on a redeployed stack: + - login / auth cookie + - Brain init `COMPLETED` + - Agent Q&A (`scripts/self-host/e2e-agent-check.py`) → `AGENT_OK` + `DASH_OK` + - `/onboarding` renders + - security smoke: anonymous Prometheus `401`, malicious kill pid `400` +3. `CHANGELOG.md` and `docs/releases/RELEASE_NOTES-vX.Y.Z.md` updated. + +## Local artifact build + +```bash +./scripts/release/build-artifacts.sh v1.0.0 +# → release-artifacts/v1.0.0/ +``` + +Skip expensive rebuilds when iterating: + +```bash +SKIP_BACKEND=1 SKIP_FRONTEND=1 ./scripts/release/build-artifacts.sh v1.0.0 +``` + +Verify checksums: + +```bash +cd release-artifacts/v1.0.0 +sha256sum -c SHA256SUMS +``` + +## Publish on GitHub + +Preferred path (CI): + +```bash +git checkout main && git pull +git tag -a v1.0.0 -m "DeepSQL v1.0.0" +git push origin v1.0.0 +``` + +Pushing the tag runs `.github/workflows/release.yml`, which: + +1. Builds source archives, backend JAR, frontend tarball, MCP pack +2. Generates CycloneDX SBOMs (best-effort) +3. Writes `SHA256SUMS` / `SHA512SUMS` + `manifest.json` +4. Creates a GitHub Release (non-draft) attaching every file under `release-artifacts/v1.0.0/` + +Manual fallback (if Actions cannot publish): + +```bash +./scripts/release/build-artifacts.sh v1.0.0 +gh release create v1.0.0 \ + --title "DeepSQL v1.0.0" \ + --notes-file docs/releases/RELEASE_NOTES-v1.0.0.md \ + release-artifacts/v1.0.0/* +``` + +## Artifact set (what “industry standard” means here) + +| Artifact | Purpose | +|----------|---------| +| `*-source.tar.gz` / `.zip` | Bit-for-bit source corresponding to the tag (`git archive`) | +| `dba-agent-backend-*.jar` | Runnable Spring Boot binary | +| `deepsql-frontend-*.tar.gz` | Static SPA to serve behind nginx | +| `deepsql-mcp-*.tgz` | CLI / MCP installable package | +| `sbom-*.cdx.json` | CycloneDX software bill of materials | +| `SHA256SUMS` / `SHA512SUMS` | Integrity verification | +| `manifest.json` | Machine-readable inventory (commit, sizes, hashes) | +| `RELEASE_NOTES.md` | Human-facing release notes | + +Docker Compose remains the primary install path (`docker compose up --build`); the JAR + frontend tarball support air-gapped / non-Compose operators. + +## Post-release + +- Confirm the Release page lists every file and checksums verify. +- Announce with the tag URL + one-line upgrade note. +- Bump versions on `main` for the next cycle only after the tag is cut (avoid tagging a commit whose pom still says the previous version). diff --git a/docs/releases/RELEASE_NOTES-v1.0.0.md b/docs/releases/RELEASE_NOTES-v1.0.0.md new file mode 100644 index 0000000..d2b2a36 --- /dev/null +++ b/docs/releases/RELEASE_NOTES-v1.0.0.md @@ -0,0 +1,56 @@ +# DeepSQL v1.0.0 + +**First public open-source release** — self-hosted database agent for PostgreSQL and MySQL. + +## Highlights + +- **Ask your database** — schema exploration, SQL generation, slow-query help, and index guidance through the web UI or MCP/CLI. +- **Brain** — indexes your schemas (including non-`public` Postgres schemas) so the agent has real context. +- **DeepSQL Agent** — per-user profiles, MCP tool access scoped to your login, dashboard HTML artifacts with a read-only query bridge. +- **BYO model** — point at OpenAI, Azure OpenAI, or any OpenAI-compatible endpoint. No vendor lock-in on inference. +- **Self-host first** — credentials stay in your vault DB; Compose builds from source. + +## Install + +```bash +git clone https://github.com/DeepSQLAI/deepsql.git +cd deepsql +git checkout v1.0.0 +cp .env.example .env +# set SECURITY_JWT_SECRET, ENCRYPTION_KEY, and DEEPSQL_CHAT_* in .env +docker compose up --build -d +``` + +Air-gapped / non-Compose: download the JAR + frontend tarball from this Release, verify `SHA256SUMS`, and follow `docs/oss-ux/RELEASE.md`. + +## Artifacts in this release + +| File | Contents | +|------|----------| +| `deepsql-1.0.0-source.tar.gz` / `.zip` | Source tree at this tag | +| `dba-agent-backend-1.0.0.jar` | Spring Boot executable | +| `deepsql-frontend-1.0.0.tar.gz` | Production static UI | +| `deepsql-mcp-0.27.0.tgz` | CLI + MCP server package | +| `sbom-*.cdx.json` | CycloneDX SBOMs | +| `SHA256SUMS` / `SHA512SUMS` | Integrity hashes | +| `manifest.json` | Machine-readable inventory | + +Verify: + +```bash +sha256sum -c SHA256SUMS +``` + +## Security notes + +Critical/high items addressed before this cut are listed in `CHANGELOG.md` and `docs/oss-ux/OSS_SECURITY_REVIEW.md`. Please report new vulnerabilities per `SECURITY.md`. + +## Verify before upgrading production + +On a staging stack, run: + +```bash +python3 scripts/self-host/e2e-agent-check.py +``` + +Expect `AGENT_OK True` and `DASH_OK True`. diff --git a/scripts/release/build-artifacts.sh b/scripts/release/build-artifacts.sh new file mode 100755 index 0000000..c78c96c --- /dev/null +++ b/scripts/release/build-artifacts.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# Build industry-standard GitHub release artifacts for DeepSQL. +# +# Produces under release-artifacts//: +# deepsql--source.tar.gz / .zip — reproducible source archives (git archive) +# dba-agent-backend-.jar — Spring Boot executable JAR +# deepsql-frontend-.tar.gz — production Vite build +# deepsql-mcp-.tgz — npm pack of @deepsql/mcp +# sbom-frontend.cdx.json — CycloneDX SBOM (npm), when tooling available +# sbom-backend.cdx.json — CycloneDX SBOM (Maven), when tooling available +# SHA256SUMS / SHA512SUMS — checksums for every artifact above +# RELEASE_NOTES.md / manifest.json +# +# Usage (from repo root): +# ./scripts/release/build-artifacts.sh [v1.0.0] +# +# Environment: +# SKIP_FRONTEND=1 skip npm ci / production build +# SKIP_BACKEND=1 skip mvn package (reuse existing JAR) +# SKIP_SBOM=1 skip CycloneDX generation +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +VERSION_RAW="${1:-}" +if [[ -z "$VERSION_RAW" ]]; then + VERSION_RAW="$(git describe --tags --exact-match 2>/dev/null || true)" +fi +if [[ -z "$VERSION_RAW" ]]; then + VERSION_RAW="v$(grep -m1 '' backend/pom.xml | sed -E 's/.*([^<]+)<\/version>.*/\1/')" +fi +VERSION="${VERSION_RAW#v}" +TAG="v${VERSION}" + +MCP_VERSION="$(python3 -c "import json; print(json.load(open('mcp/package.json'))['version'])")" +# Must NOT live under Vite's outDir (`dist/`): `vite build` empties that tree and +# would delete source archives / JAR mid-run if we staged here. +OUT="$ROOT/release-artifacts/${TAG}" +rm -rf "$OUT" +mkdir -p "$OUT" + +echo "==> DeepSQL release artifacts for ${TAG}" +echo " output: ${OUT}" + +# ── Source archives (git tree at HEAD; excludes untracked / ignored) ────────── +echo "==> Source archives" +PREFIX="deepsql-${VERSION}" +git archive --format=tar.gz --prefix="${PREFIX}/" HEAD > "${OUT}/${PREFIX}-source.tar.gz" +git archive --format=zip --prefix="${PREFIX}/" HEAD > "${OUT}/${PREFIX}-source.zip" + +# ── Backend JAR ─────────────────────────────────────────────────────────────── +if [[ "${SKIP_BACKEND:-0}" != "1" ]]; then + echo "==> Backend package (skip tests)" + (cd backend && ./mvnw -B -ntp -DskipTests package) +fi +JAR_SRC="backend/target/dba-agent-backend-${VERSION}.jar" +if [[ ! -f "$JAR_SRC" ]]; then + JAR_SRC="$(ls -1 backend/target/dba-agent-backend-*.jar 2>/dev/null | head -1 || true)" +fi +if [[ -z "${JAR_SRC}" || ! -f "$JAR_SRC" ]]; then + echo "ERROR: backend JAR not found under backend/target/" >&2 + exit 1 +fi +cp -f "$JAR_SRC" "${OUT}/$(basename "$JAR_SRC")" + +# ── Frontend production build ───────────────────────────────────────────────── +if [[ "${SKIP_FRONTEND:-0}" != "1" ]]; then + echo "==> Frontend production build" + if [[ ! -d node_modules ]]; then + npm ci + fi + npm run build:production +fi + +FRONTEND_DIST="$ROOT/dist" +if [[ ! -f "$FRONTEND_DIST/index.html" ]]; then + echo "ERROR: frontend build output missing (expected ${FRONTEND_DIST}/index.html)" >&2 + exit 1 +fi +tar -C "$FRONTEND_DIST" -czf "${OUT}/deepsql-frontend-${VERSION}.tar.gz" . + +# ── MCP npm pack ────────────────────────────────────────────────────────────── +echo "==> MCP package" +( + cd mcp + if [[ ! -d node_modules ]]; then + npm ci --omit=dev 2>/dev/null || npm install --omit=dev + fi + npm pack --pack-destination "$OUT" +) +MCP_PACK="$(ls -1 "${OUT}"/@deepsql-mcp-*.tgz 2>/dev/null | head -1 || true)" +if [[ -n "$MCP_PACK" ]]; then + mv -f "$MCP_PACK" "${OUT}/deepsql-mcp-${MCP_VERSION}.tgz" +fi + +# ── SBOMs (best-effort; never fail the release build) ───────────────────────── +if [[ "${SKIP_SBOM:-0}" != "1" ]]; then + echo "==> SBOMs (CycloneDX)" + if command -v npx >/dev/null 2>&1; then + npx --yes @cyclonedx/cyclonedx-npm@3.1.0 \ + --output-file "${OUT}/sbom-frontend.cdx.json" \ + --output-reproducible \ + --ignore-npm-errors \ + || echo "WARN: frontend SBOM generation skipped" + fi + if [[ -x backend/mvnw ]]; then + if (cd backend && ./mvnw -B -ntp -DskipTests \ + org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom \ + -DoutputFormat=json \ + -DoutputName=bom \ + -DincludeDevelopmentScope=false); then + cp -f backend/target/bom.json "${OUT}/sbom-backend.cdx.json" + else + echo "WARN: backend SBOM generation skipped" + fi + fi +fi + +# ── Release notes ───────────────────────────────────────────────────────────── +for candidate in \ + "docs/releases/RELEASE_NOTES-${TAG}.md" \ + "docs/releases/RELEASE_NOTES-v${VERSION}.md" \ + "CHANGELOG.md" +do + if [[ -f "$candidate" ]]; then + cp -f "$candidate" "${OUT}/RELEASE_NOTES.md" + break + fi +done + +# ── Checksums + manifest ────────────────────────────────────────────────────── +echo "==> Checksums + manifest" +export RELEASE_OUT="$OUT" RELEASE_VERSION="$VERSION" RELEASE_TAG="$TAG" RELEASE_MCP="$MCP_VERSION" +export RELEASE_COMMIT="$(git rev-parse HEAD)" +export RELEASE_DESCRIBE="$(git describe --always --dirty 2>/dev/null || true)" +python3 <<'PY' +import hashlib, json, os, time +from pathlib import Path + +out = Path(os.environ["RELEASE_OUT"]) +files = sorted( + p for p in out.iterdir() + if p.is_file() and not p.name.startswith(".") and not p.name.startswith("SHA") +) +lines256, lines512 = [], [] +artifacts = [] +for p in files: + data = p.read_bytes() + h256 = hashlib.sha256(data).hexdigest() + h512 = hashlib.sha512(data).hexdigest() + lines256.append(f"{h256} {p.name}") + lines512.append(f"{h512} {p.name}") + artifacts.append({"name": p.name, "bytes": len(data), "sha256": h256}) + +(out / "SHA256SUMS").write_text("\n".join(lines256) + ("\n" if lines256 else "")) +(out / "SHA512SUMS").write_text("\n".join(lines512) + ("\n" if lines512 else "")) + +# Re-hash including checksum files themselves for the manifest listing +all_files = sorted(p for p in out.iterdir() if p.is_file() and not p.name.startswith(".")) +manifest_artifacts = [] +for p in all_files: + data = p.read_bytes() + manifest_artifacts.append({ + "name": p.name, + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + }) + +manifest = { + "product": "DeepSQL", + "version": os.environ["RELEASE_VERSION"], + "tag": os.environ["RELEASE_TAG"], + "mcpVersion": os.environ["RELEASE_MCP"], + "gitCommit": os.environ["RELEASE_COMMIT"], + "gitDescribe": os.environ["RELEASE_DESCRIBE"], + "createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "artifacts": manifest_artifacts, +} +(out / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") +print(json.dumps(manifest, indent=2)) +PY + +echo "" +echo "✓ Artifacts ready in ${OUT}" +ls -lh "$OUT"