fix(ci): skip ClawHub publish gracefully when version already exists #677
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |