Skip to content

fix(ci): skip ClawHub publish gracefully when version already exists #677

fix(ci): skip ClawHub publish gracefully when version already exists

fix(ci): skip ClawHub publish gracefully when version already exists #677

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*'
permissions:
actions: write
contents: write
id-token: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: packages/python-client/pyproject.toml
- run: npm ci
- run: npm audit --audit-level=high
- run: npm run openapi:check
- run: npm run sdk:ts:check
- run: npm run sdk:py:check
- run: npx tsc --noEmit
- run: npm run build:dashboard
- run: AEGIS_REQUIRE_DASHBOARD_COPY=true npm run build
- name: Validate packaged dashboard bundle
run: test -f dist/dashboard/index.html
- run: npm test
- run: cd dashboard && npx vitest run
- name: Pack artifact
run: npm pack --pack-destination /tmp
- uses: actions/upload-artifact@v4
with:
name: package
path: /tmp/onestepat4time-aegis-*.tgz
retention-days: 1
fault-harness:
name: Fault harness (${{ matrix.label }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- id: jsonl-corruption
label: JSONL corruption
tests: >-
src/__tests__/transcript.test.ts
src/__tests__/suppress-882.test.ts
- id: channel-5xx
label: channel 5xx
tests: >-
src/__tests__/channels/manager.test.ts
src/__tests__/webhook-retry.test.ts
- id: sse-drop
label: SSE drop
tests: >-
src/__tests__/sse-writer.test.ts
src/__tests__/sse-limiter.test.ts
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- name: Record fault harness target
shell: bash
run: |
REPORT_DIR="fault-harness-output/${{ matrix.id }}"
mkdir -p "$REPORT_DIR"
{
echo "mode=${{ matrix.id }}"
echo "label=${{ matrix.label }}"
echo "tests=${{ matrix.tests }}"
echo "ref=${GITHUB_REF}"
echo "sha=${GITHUB_SHA}"
} > "$REPORT_DIR/metadata.txt"
- name: Run existing fault harness
shell: bash
env:
AEGIS_FAULT_INJECTION: '1'
AEGIS_FAULT_SEED: '1936'
run: |
REPORT_DIR="fault-harness-output/${{ matrix.id }}"
set -o pipefail
npm run test:fault-harness -- --reporter=verbose 2>&1 | tee "$REPORT_DIR/base-harness.log"
- name: Run failure-mode suite
shell: bash
env:
AEGIS_FAULT_INJECTION: '1'
AEGIS_FAULT_SEED: '1936'
run: |
REPORT_DIR="fault-harness-output/${{ matrix.id }}"
set -o pipefail
npx vitest run ${{ matrix.tests }} --reporter=verbose 2>&1 | tee "$REPORT_DIR/mode-suite.log"
- uses: actions/upload-artifact@v4
if: always()
with:
name: fault-harness-${{ matrix.id }}
path: fault-harness-output/${{ matrix.id }}/
retention-days: 30
generate-sbom:
needs: [test, fault-harness]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Generate SBOM
run: |
npm ci
npx --yes @cyclonedx/cyclonedx-npm --output-file sbom.json --output-format JSON
cat sbom.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'SBOM: {len(d.get(\"components\",[]))} components')"
- uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.json
retention-days: 30
generate-checksums:
needs: [test, fault-harness]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: package
path: /tmp
- name: Generate SHA256 checksums
run: |
cd /tmp
for f in *.tgz; do
echo "$(sha256sum "$f" | cut -d' ' -f1) $(basename "$f")" >> checksums.txt
done
cat checksums.txt
- uses: actions/upload-artifact@v4
with:
name: checksums
path: /tmp/checksums.txt
retention-days: 30
release-preflight:
name: Release preflight
needs: [generate-checksums, generate-sbom]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- uses: azure/setup-helm@v5
- name: Install cosign
uses: sigstore/cosign-installer@v3
- uses: actions/download-artifact@v8
with:
name: package
path: release-preflight/package
- uses: actions/download-artifact@v8
with:
name: checksums
path: release-preflight/checksums
- uses: actions/download-artifact@v8
with:
name: sbom
path: release-preflight/sbom
- name: Validate pre-publish release surfaces
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
sanitize_log() {
python3 -c '
import os
import re
import sys
data = sys.stdin.read()
data = re.sub(
r"https://x-access-token:[^@]+@github\.com/",
"https://x-access-token:***@github.com/",
data,
)
for value in (os.environ.get("GITHUB_TOKEN", ""), os.environ.get("NODE_AUTH_TOKEN", "")):
if value:
data = data.replace(value, "***")
sys.stdout.write(data)
'
}
shopt -s nullglob
packages=(release-preflight/package/onestepat4time-aegis-*.tgz)
if [ "${#packages[@]}" -ne 1 ]; then
echo "::error::Expected exactly one root npm tarball artifact, found ${#packages[@]}."
exit 1
fi
PACKAGE="${packages[0]}"
if [ ! -s "${PACKAGE}" ]; then
echo "::error::Root npm tarball artifact is empty: ${PACKAGE}"
exit 1
fi
CHECKSUMS="release-preflight/checksums/checksums.txt"
if [ ! -s "${CHECKSUMS}" ]; then
echo "::error::Missing checksums artifact: ${CHECKSUMS}"
exit 1
fi
PACKAGE_BASENAME=$(basename "${PACKAGE}")
CHECKSUM=$(sha256sum "${PACKAGE}" | cut -d' ' -f1)
ARTIFACT_CHECKSUM=$(awk -v file="${PACKAGE_BASENAME}" '$2 == file { print $1 }' "${CHECKSUMS}")
if [ -z "${ARTIFACT_CHECKSUM}" ]; then
echo "::error::Checksums artifact does not contain ${PACKAGE_BASENAME}."
exit 1
fi
if [ "${CHECKSUM}" != "${ARTIFACT_CHECKSUM}" ]; then
echo "::error::Checksum mismatch for ${PACKAGE_BASENAME}."
exit 1
fi
SBOM="release-preflight/sbom/sbom.json"
if [ ! -s "${SBOM}" ]; then
echo "::error::Missing SBOM artifact: ${SBOM}"
exit 1
fi
python3 - <<'PY'
import json
from pathlib import Path
sbom = json.loads(Path("release-preflight/sbom/sbom.json").read_text(encoding="utf-8"))
if sbom.get("bomFormat") != "CycloneDX":
raise SystemExit("SBOM is not CycloneDX JSON")
if not sbom.get("components"):
raise SystemExit("SBOM has no components")
print(f"SBOM components: {len(sbom.get('components', []))}")
PY
TARBALL_NAME=$(tar -xOf "${PACKAGE}" package/package.json | node -e 'const fs = require("fs"); const pkg = JSON.parse(fs.readFileSync(0, "utf8")); console.log(pkg.name);')
TARBALL_VERSION=$(tar -xOf "${PACKAGE}" package/package.json | node -e 'const fs = require("fs"); const pkg = JSON.parse(fs.readFileSync(0, "utf8")); console.log(pkg.version);')
TAG="${GITHUB_REF#refs/tags/}"
TAG_VERSION="${TAG#v}"
if [ "${TAG}" = "${GITHUB_REF}" ] || [ "${TAG}" = "${TAG_VERSION}" ]; then
echo "::error::Release workflow must run from a v-prefixed tag; got ${GITHUB_REF}."
exit 1
fi
if [[ "${TAG_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-preview[.-][0-9]+$ ]]; then
TAG_OBJECT_TYPE=$(git cat-file -t "${GITHUB_REF_NAME}" 2>/dev/null || true)
if [ "${TAG_OBJECT_TYPE}" != "tag" ] ||
! git cat-file tag "${GITHUB_REF_NAME}" | grep -Fxq "recovery-release: true"; then
echo "::error::Numbered preview tags are recovery-only and require an annotated tag containing 'recovery-release: true'."
exit 1
fi
fi
git fetch --no-tags origin main:refs/remotes/origin/main
TAG_COMMIT=$(git rev-list -n 1 "${GITHUB_REF_NAME}")
if ! git merge-base --is-ancestor "${TAG_COMMIT}" origin/main; then
echo "::error::Real release publishing is allowed only for tags whose commit is reachable from origin/main."
echo "::error::Tag ${TAG} points to ${TAG_COMMIT}, which is not contained in origin/main."
echo "::error::Use the release dry-run workflow on develop; promote to main before pushing a publishing tag."
exit 1
fi
if [ "${TARBALL_NAME}" != "@onestepat4time/aegis" ]; then
echo "::error::Unexpected root package name in tarball: ${TARBALL_NAME}"
exit 1
fi
if [ "${TARBALL_VERSION}" != "${TAG_VERSION}" ]; then
echo "::error::Tarball version ${TARBALL_VERSION} does not match tag version ${TAG_VERSION}."
exit 1
fi
if [[ "${TAG_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
DIST_TAG=latest
elif [[ "${TAG_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(preview|alpha|beta|rc)([.-]?[0-9]+)?$ ]]; then
DIST_TAG="${BASH_REMATCH[1]}"
else
echo "::error::Unsupported release tag version: ${TAG_VERSION}"
exit 1
fi
echo "::notice::Release tag ${TAG} resolves to npm version ${TAG_VERSION} with dist-tag ${DIST_TAG}."
cat > release-preflight/predicate.json << EOF
{
"packageName": "@onestepat4time/aegis",
"version": "${TAG_VERSION}",
"digest": "sha256:${CHECKSUM}",
"registry": "registry.npmjs.org",
"workflow": "https://github.com/OneStepAt4time/aegis/.github/workflows/release.yml"
}
EOF
python3 -m json.tool release-preflight/predicate.json > /dev/null
cosign attest-blob \
--yes \
--tlog-upload=false \
--bundle release-preflight/package.sigstore \
--predicate release-preflight/predicate.json \
"${PACKAGE}"
cosign verify-blob-attestation \
--insecure-ignore-tlog \
--certificate-identity-regexp "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release\\.yml@refs/tags/v.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--bundle release-preflight/package.sigstore \
"${PACKAGE}" > release-preflight/verified-attestation.log
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
REPO_NAME="${GITHUB_REPOSITORY#*/}"
ROOT_URL="https://${OWNER_LOWER}.github.io/${REPO_NAME}/helm"
AUTHENTICATED_REMOTE_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
mkdir -p release-preflight/chart-artifacts
helm lint deploy/helm/aegis
helm package deploy/helm/aegis \
--destination release-preflight/chart-artifacts \
--version "${TAG_VERSION}" \
--app-version "${TAG_VERSION}"
if ! git clone --no-tags "${AUTHENTICATED_REMOTE_URL}" release-preflight/pages-repo > release-preflight/clone.log 2>&1; then
echo "::error::Failed to clone ${GITHUB_REPOSITORY} for Helm pages preflight."
sanitize_log < release-preflight/clone.log >&2
exit 1
fi
cd release-preflight/pages-repo
if git show-ref --verify --quiet refs/remotes/origin/gh-pages; then
git checkout -B gh-pages origin/gh-pages
else
git checkout --orphan gh-pages
find . -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
echo "# Aegis Helm repository storage" > README.md
fi
mkdir -p helm
cp ../chart-artifacts/*.tgz helm/
if [ -f helm/index.yaml ]; then
helm repo index helm --url "${ROOT_URL}" --merge helm/index.yaml
else
helm repo index helm --url "${ROOT_URL}"
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .
if git diff --cached --quiet; then
echo "::notice::Helm gh-pages preflight produced no index changes."
else
git commit -m "chore(release): preflight Helm chart ${TAG}" > /dev/null
echo "::notice::Helm gh-pages preflight created a local commit only; no push was performed."
fi
- name: Upload Helm chart artifact
uses: actions/upload-artifact@v4
with:
name: helm-chart
path: release-preflight/chart-artifacts/*.tgz
retention-days: 1
if-no-files-found: error
# Gate: verify the tag does not already exist on the remote.
# Prevents accidental re-publish or tag overwrite (M4).
check-tag-freshness:
needs: [release-preflight]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Fail if tag already exists on remote
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
# Tag-push events: GitHub already rejects pushes to existing tags (unless force-pushed,
# which branch protection blocks). The tag was just created — freshness is guaranteed.
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
echo "::notice::Tag ${TAG} was just pushed — skipping freshness check for tag-push event."
exit 0
fi
# For workflow_dispatch: guard against accidentally re-running for an existing tag.
# Recovery releases skip this check (they always retag).
if git cat-file -t "${TAG}" 2>/dev/null | grep -qx tag &&
git cat-file tag "${TAG}" 2>/dev/null | grep -Fxq "recovery-release: true"; then
echo "::notice::Tag ${TAG} is a recovery release — skipping freshness check."
elif git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "::error::Tag ${TAG} already exists on origin. Refusing to publish."
echo "::error::If this is intentional, delete the tag first and re-push."
exit 1
else
echo "::notice::Tag ${TAG} is fresh — proceeding with publish."
fi
publish-npm:
needs: [attest-npm, ensure-github-release, check-tag-freshness]
environment: publish
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- uses: actions/download-artifact@v8
with:
name: package
path: .
- name: Validate package version and dist-tag
id: release
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
packages=(*.tgz)
if [ "${#packages[@]}" -ne 1 ]; then
echo "::error::Expected exactly one root npm tarball artifact, found ${#packages[@]}."
exit 1
fi
PACKAGE="${packages[0]}"
VERSION=$(tar -xOf "${PACKAGE}" package/package.json | node -e 'const fs = require("fs"); const pkg = JSON.parse(fs.readFileSync(0, "utf8")); console.log(pkg.version);')
TAG_VERSION="${GITHUB_REF_NAME#v}"
if [ "${GITHUB_REF_NAME}" = "${TAG_VERSION}" ]; then
echo "::error::Release workflow must run from a v-prefixed tag; got ${GITHUB_REF_NAME}."
exit 1
fi
if [ "${VERSION}" != "${TAG_VERSION}" ]; then
echo "::error::Tarball version ${VERSION} does not match tag version ${TAG_VERSION}."
exit 1
fi
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
DIST_TAG=latest
elif [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(preview|alpha|beta|rc)([.-]?[0-9]+)?$ ]]; then
DIST_TAG="${BASH_REMATCH[1]}"
else
echo "::error::Unsupported npm release version: ${VERSION}"
exit 1
fi
echo "package=${PACKAGE}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "dist-tag=${DIST_TAG}" >> "$GITHUB_OUTPUT"
- name: Check whether root npm version already exists
id: npm-version
shell: bash
run: |
set -euo pipefail
PACKAGE_SPEC="@onestepat4time/aegis@${{ steps.release.outputs.version }}"
set +e
VIEW_OUTPUT=$(npm view "${PACKAGE_SPEC}" version 2>&1)
VIEW_STATUS=$?
set -e
if [ "${VIEW_STATUS}" -eq 0 ]; then
echo "::notice::${PACKAGE_SPEC} already exists on npm (${VIEW_OUTPUT}); skipping root npm publish."
echo "exists=true" >> "$GITHUB_OUTPUT"
elif grep -Eiq '(E404|404 Not Found|No match found|not found)' <<< "${VIEW_OUTPUT}"; then
echo "::notice::${PACKAGE_SPEC} does not exist on npm; publishing this tarball."
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "::error::npm view failed while checking ${PACKAGE_SPEC}."
printf '%s\n' "${VIEW_OUTPUT}" | sed -E 's#//registry\.npmjs\.org/:_authToken=[^[:space:]]+#//registry.npmjs.org/:_authToken=***#g' >&2
exit 1
fi
- name: Publish root npm package
if: steps.npm-version.outputs.exists != 'true'
run: npm publish --provenance --access public --tag ${{ steps.release.outputs.dist-tag }} "${{ steps.release.outputs.package }}"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
publish-typescript-sdk:
needs: publish-npm
environment: publish
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: |
package-lock.json
packages/client/package-lock.json
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm --prefix packages/client ci
- name: Determine release version and dist-tag
id: release
shell: bash
run: |
set -euo pipefail
VERSION=${GITHUB_REF_NAME#v}
if [ "${GITHUB_REF_NAME}" = "${VERSION}" ]; then
echo "::error::Release workflow must run from a v-prefixed tag; got ${GITHUB_REF_NAME}."
exit 1
fi
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
DIST_TAG=latest
elif [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(preview|alpha|beta|rc)([.-]?[0-9]+)?$ ]]; then
DIST_TAG="${BASH_REMATCH[1]}"
else
echo "::error::Unsupported TypeScript SDK release version: ${VERSION}"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "dist-tag=${DIST_TAG}" >> "$GITHUB_OUTPUT"
- name: Generate OpenAPI contract and TypeScript SDK
run: |
npm run openapi:sync
npm --prefix packages/client run generate
- name: Set TypeScript SDK package version
run: npm --prefix packages/client version "${{ steps.release.outputs.version }}" --no-git-tag-version --allow-same-version
- name: Build TypeScript SDK
run: npm --prefix packages/client run build
- name: Check whether TypeScript SDK npm version already exists
id: ts-sdk-version
shell: bash
run: |
set -euo pipefail
PACKAGE_SPEC="@onestepat4time/aegis-client@${{ steps.release.outputs.version }}"
set +e
VIEW_OUTPUT=$(npm view "${PACKAGE_SPEC}" version 2>&1)
VIEW_STATUS=$?
set -e
if [ "${VIEW_STATUS}" -eq 0 ]; then
echo "::notice::${PACKAGE_SPEC} already exists on npm (${VIEW_OUTPUT}); skipping TypeScript SDK npm publish."
echo "exists=true" >> "$GITHUB_OUTPUT"
elif grep -Eiq '(E404|404 Not Found|No match found|not found)' <<< "${VIEW_OUTPUT}"; then
echo "::notice::${PACKAGE_SPEC} does not exist on npm; publishing this package."
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "::error::npm view failed while checking ${PACKAGE_SPEC}."
printf '%s\n' "${VIEW_OUTPUT}" | sed -E 's#//registry\.npmjs\.org/:_authToken=[^[:space:]]+#//registry.npmjs.org/:_authToken=***#g' >&2
exit 1
fi
- name: Publish TypeScript SDK to npm
if: steps.ts-sdk-version.outputs.exists != 'true'
working-directory: packages/client
run: npm publish --provenance --access public --tag ${{ steps.release.outputs.dist-tag }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
publish-python-sdk:
needs: publish-typescript-sdk
environment: pypi
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: packages/python-client/pyproject.toml
- run: npm ci
- name: Install Python SDK build dependencies
run: |
python -m pip install --upgrade pip build twine
python -m pip install -e "packages/python-client[dev]"
- name: Determine release version
id: release
shell: bash
run: |
set -euo pipefail
TAG_VERSION="${GITHUB_REF_NAME#v}"
if [[ "$TAG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
PYTHON_VERSION="${BASH_REMATCH[1]}"
elif [[ "$TAG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-preview([.-]?([0-9]+))?$ ]]; then
PYTHON_VERSION="${BASH_REMATCH[1]}.dev${BASH_REMATCH[3]:-0}"
elif [[ "$TAG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-alpha([.-]?([0-9]+))?$ ]]; then
PYTHON_VERSION="${BASH_REMATCH[1]}a${BASH_REMATCH[3]:-0}"
elif [[ "$TAG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-beta([.-]?([0-9]+))?$ ]]; then
PYTHON_VERSION="${BASH_REMATCH[1]}b${BASH_REMATCH[3]:-0}"
elif [[ "$TAG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-rc([.-]?([0-9]+))?$ ]]; then
PYTHON_VERSION="${BASH_REMATCH[1]}rc${BASH_REMATCH[3]:-0}"
else
echo "Unsupported release tag version for PyPI: ${TAG_VERSION}" >&2
exit 1
fi
export PYTHON_VERSION
python - <<'PY'
import os
from packaging.version import Version
version = os.environ["PYTHON_VERSION"]
normalized = str(Version(version))
if normalized != version:
raise SystemExit(f"Python version {version!r} normalizes to {normalized!r}")
print(f"PEP 440 Python SDK version: {version}")
PY
echo "tag_version=${TAG_VERSION}" >> "$GITHUB_OUTPUT"
echo "python_version=${PYTHON_VERSION}" >> "$GITHUB_OUTPUT"
- name: Generate OpenAPI contract and Python SDK
run: |
npm run openapi:sync
npm run sdk:py:generate
- name: Set Python SDK package version
env:
VERSION: ${{ steps.release.outputs.python_version }}
run: |
python - <<'PY'
from pathlib import Path
import os
import re
version = os.environ["VERSION"]
pyproject = Path("packages/python-client/pyproject.toml")
init_file = Path("packages/python-client/src/aegis_python_client/__init__.py")
def replace_version(path, pattern, replacement):
updated, count = re.subn(
pattern,
replacement,
path.read_text(encoding="utf-8"),
flags=re.MULTILINE,
)
if count != 1:
raise SystemExit(
f"Expected exactly one version replacement in {path}, found {count}"
)
path.write_text(updated, encoding="utf-8")
replace_version(pyproject, r'^version = ".*"$', f'version = "{version}"')
replace_version(init_file, r'^__version__ = ".*"$', f'__version__ = "{version}"')
PY
- name: Build Python SDK distributions
working-directory: packages/python-client
run: python -m build
- name: Validate Python SDK distributions
working-directory: packages/python-client
run: |
python -m twine check dist/*
python - <<'PY'
import email
import zipfile
from pathlib import Path
wheels = sorted(Path("dist").glob("ag_client-*-py3-none-any.whl"))
if len(wheels) != 1:
raise SystemExit(f"Expected one ag-client wheel, found {len(wheels)}")
with zipfile.ZipFile(wheels[0]) as archive:
metadata_name = next(
name for name in archive.namelist() if name.endswith(".dist-info/METADATA")
)
metadata = email.message_from_bytes(archive.read(metadata_name))
if metadata["Name"] != "ag-client":
raise SystemExit(f"Unexpected Python distribution name: {metadata['Name']}")
PY
- name: Publish Python SDK to PyPI
id: publish-python-sdk
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: packages/python-client/dist
skip-existing: true
ensure-github-release:
needs: release-preflight
runs-on: ubuntu-latest
steps:
- name: Ensure GitHub Release exists for tag
run: |
TAG=${GITHUB_REF#refs/tags/}
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $TAG already exists"
exit 0
fi
if [[ "$TAG" == *-preview* || "$TAG" == *-alpha* || "$TAG" == *-beta* || "$TAG" == *-rc* ]]; then
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --prerelease --generate-notes
else
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --generate-notes
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
attach-checksums:
needs: [publish-npm, generate-checksums, ensure-github-release]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: checksums
path: .
- name: Attach checksums to GitHub Release
run: |
TAG=${GITHUB_REF#refs/tags/}
gh release upload "$TAG" checksums.txt --repo "$GITHUB_REPOSITORY" --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
attach-sbom:
needs: [publish-npm, generate-sbom, ensure-github-release]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: sbom
path: .
- name: Attach SBOM to GitHub Release
run: |
TAG=${GITHUB_REF#refs/tags/}
gh release upload "$TAG" sbom.json --repo "$GITHUB_REPOSITORY" --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
generate-predicate:
needs: generate-checksums
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: checksums
path: /tmp
- name: Generate attestation predicate
run: |
TAG=${GITHUB_REF#refs/tags/}
CHECKSUM=$(grep '.tgz$' /tmp/checksums.txt | awk '{print $1}')
cat > /tmp/predicate.json << EOF
{
"packageName": "@onestepat4time/aegis",
"version": "${TAG#v}",
"digest": "sha256:${CHECKSUM}",
"registry": "registry.npmjs.org",
"workflow": "https://github.com/OneStepAt4time/aegis/.github/workflows/release.yml"
}
EOF
cat /tmp/predicate.json
- uses: actions/upload-artifact@v4
with:
name: predicate
path: /tmp/predicate.json
retention-days: 365
attest-npm:
needs: [release-preflight, generate-predicate]
runs-on: ubuntu-latest
steps:
- name: Install cosign
uses: sigstore/cosign-installer@v3
- uses: actions/download-artifact@v8
with:
name: package
path: /tmp
- uses: actions/download-artifact@v8
with:
name: predicate
path: /tmp
- name: Attest npm package with Sigstore
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PACKAGE=$(ls /tmp/onestepat4time-aegis-*.tgz | head -1)
cosign attest-blob \
--yes \
--bundle /tmp/package.sigstore \
--predicate /tmp/predicate.json \
"${PACKAGE}"
- uses: actions/upload-artifact@v4
with:
name: npm-attestation
path: /tmp/package.sigstore
retention-days: 365
attach-attestation:
needs: [attest-npm, ensure-github-release]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: npm-attestation
path: .
- name: Attach Sigstore attestation to GitHub Release
run: |
TAG=${GITHUB_REF#refs/tags/}
gh release upload "$TAG" package.sigstore --repo "$GITHUB_REPOSITORY" --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-helm-chart:
needs: [publish-npm, ensure-github-release]
runs-on: ubuntu-latest
outputs:
pages_updated: ${{ steps.publish.outputs.pages_updated }}
pages_sha: ${{ steps.publish.outputs.pages_sha }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: azure/setup-helm@v5
- uses: actions/download-artifact@v8
with:
name: helm-chart
path: chart-artifacts
- name: Publish Helm repo contents
id: publish
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
OWNER_LOWER=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
REPO_NAME="${GITHUB_REPOSITORY#*/}"
ROOT_URL="https://${OWNER_LOWER}.github.io/${REPO_NAME}/helm"
AUTHENTICATED_REMOTE_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
TAG_VERSION="${GITHUB_REF_NAME#v}"
if [ "${GITHUB_REF_NAME}" = "${TAG_VERSION}" ]; then
echo "::error::Release workflow must run from a v-prefixed tag; got ${GITHUB_REF_NAME}."
exit 1
fi
sanitize_git_log() {
python3 - <<'PY'
import os
import re
import sys
data = sys.stdin.read()
data = re.sub(
r"https://x-access-token:[^@]+@github\.com/",
"https://x-access-token:***@github.com/",
data,
)
token = os.environ.get("GITHUB_TOKEN", "")
if token:
data = data.replace(token, "***")
sys.stdout.write(data)
PY
}
rm -rf pages-repo
# Helm chart artifact was built by release-preflight and downloaded above.
helm lint deploy/helm/aegis
if [ ! -f chart-artifacts/*.tgz ]; then
echo "::error::Helm chart artifact not found in chart-artifacts/."
exit 1
fi
if ! git clone --no-tags "${AUTHENTICATED_REMOTE_URL}" pages-repo >clone.log 2>&1; then
echo "::error::Failed to clone ${GITHUB_REPOSITORY} for Helm pages publish."
sanitize_git_log < clone.log >&2
exit 1
fi
cd pages-repo
if git show-ref --verify --quiet refs/remotes/origin/gh-pages; then
git checkout -B gh-pages origin/gh-pages
else
git checkout --orphan gh-pages
find . -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
echo "# Aegis Helm repository storage" > README.md
fi
mkdir -p helm
cp ../chart-artifacts/*.tgz helm/
if [ -f helm/index.yaml ]; then
helm repo index helm --url "${ROOT_URL}" --merge helm/index.yaml
else
helm repo index helm --url "${ROOT_URL}"
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .
if git diff --cached --quiet; then
echo "pages_updated=false" >> "$GITHUB_OUTPUT"
echo "pages_sha=" >> "$GITHUB_OUTPUT"
exit 0
fi
TAG=${GITHUB_REF#refs/tags/}
git commit -m "chore(release): publish Helm chart ${TAG}"
PAGES_SHA=$(git rev-parse HEAD)
if ! git push origin gh-pages >push.log 2>&1; then
echo "::error::Failed to push gh-pages for Helm pages publish."
sanitize_git_log < push.log >&2
exit 1
fi
echo "pages_updated=true" >> "$GITHUB_OUTPUT"
echo "pages_sha=${PAGES_SHA}" >> "$GITHUB_OUTPUT"
refresh-pages:
needs: publish-helm-chart
if: needs.publish-helm-chart.outputs.pages_updated == 'true'
runs-on: ubuntu-latest
steps:
- name: Wait for gh-pages branch to reach the published commit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_SHA: ${{ needs.publish-helm-chart.outputs.pages_sha }}
shell: bash
run: |
set -euo pipefail
for _ in $(seq 1 20); do
CURRENT_SHA=$(gh api "repos/${GITHUB_REPOSITORY}/branches/gh-pages" --jq '.commit.sha')
if [ "${CURRENT_SHA}" = "${TARGET_SHA}" ]; then
exit 0
fi
sleep 3
done
echo "gh-pages did not update to ${TARGET_SHA} before dispatching Pages." >&2
exit 1
- name: Trigger GitHub Pages rebuild
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run pages.yml --repo "$GITHUB_REPOSITORY" --ref main
publish-clawhub:
needs: publish-npm
runs-on: ubuntu-latest
if: success()
# Best-effort: a ClawHub failure is surfaced (job marked failed) but does not
# block the rest of the workflow. cleanup-release-branch intentionally does
# NOT depend on this job so a ClawHub-only failure cannot silently hide
# behind a green cleanup.
continue-on-error: true
env:
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: ClawHub Login
if: ${{ env.CLAWHUB_TOKEN != '' }}
run: npx clawhub@latest login --token "$CLAWHUB_TOKEN"
- name: Publish to ClawHub
if: ${{ env.CLAWHUB_TOKEN != '' }}
run: |
set -euo pipefail
VERSION=$(node -p "require('./package.json').version")
set +e
OUTPUT=$(npx clawhub@latest publish skill/ --slug onestep-aegis --name "Aegis Bridge" --version "$VERSION" --changelog "Release v$VERSION - HTTP/MCP Claude Code orchestration" 2>&1)
STATUS=$?
set -e
echo "$OUTPUT"
if [ $STATUS -ne 0 ] && echo "$OUTPUT" | grep -qi "already exists"; then
echo "::notice::ClawHub version $VERSION already exists — skipping."
elif [ $STATUS -ne 0 ]; then
exit $STATUS
fi
# H1: SLSA build provenance attestation.
# Generates machine-readable provenance for every release artifact.
attest-build-provenance:
needs:
- publish-npm
- publish-typescript-sdk
- publish-python-sdk
- publish-helm-chart
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v8
with:
name: package
path: .
- uses: actions/download-artifact@v8
with:
name: helm-chart
path: deploy/helm/aegis
- name: Generate build provenance attestation
uses: actions/attest-build-provenance@v4
with:
subject-path: |
*.tgz
deploy/helm/aegis/*.tgz
cleanup-release-branch:
# ClawHub publish is intentionally NOT a dependency: ClawHub is best-effort
# and runs with continue-on-error at the job level. Excluding it here keeps
# cleanup safe and ensures a ClawHub-only failure remains visible (the job
# is marked failed) rather than hidden behind a green cleanup.
needs:
- attach-checksums
- attach-sbom
- attach-attestation
- publish-helm-chart
- publish-typescript-sdk
- publish-python-sdk
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Delete completed release branch when present
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
RELEASE_BRANCH="release/${VERSION}"
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BRANCH}" >/dev/null 2>&1; then
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/heads/${RELEASE_BRANCH}"
echo "::notice::Deleted completed release branch ${RELEASE_BRANCH}."
else
echo "::notice::No release branch ${RELEASE_BRANCH} exists; nothing to delete."
fi