Files
molecule-ai-sdk/.gitea/workflows/publish-sdk.yml
T
hongmingandClaude Opus 5 4a1bba7e9c
contracts-codegen-drift / codegen-drift (pull_request) Successful in 14s
Test / skill-framework-cli (pull_request) Successful in 19s
Test / package-smoke (pull_request) Successful in 22s
contracts-codegen-drift / validate (pull_request) Successful in 24s
contracts-codegen-drift / contract-conformance (pull_request) Successful in 16s
contracts-codegen-drift / plugin-state-conformance (pull_request) Successful in 17s
contracts-codegen-drift / adapter-registry-conformance (pull_request) Successful in 20s
contracts-codegen-drift / prompt-canonical-text-conformance (pull_request) Successful in 14s
contracts-codegen-drift / native-plugins-registry-conformance (pull_request) Successful in 20s
contracts-codegen-drift / workspace-routes-manifest-conformance (pull_request) Successful in 16s
contracts-codegen-drift / org-template declares something (pull_request) Successful in 16s
contracts-codegen-drift / branding-conformance (pull_request) Successful in 16s
contracts-codegen-drift / platform-identity-gate-conformance (pull_request) Successful in 17s
meta-ci-advisory / meta (advisory) (pull_request) Successful in 5s
contracts-codegen-drift / go-parity (pull_request) Successful in 14s
llm-registry-ssot-drift / llm-registry-ssot-drift (pull_request) Successful in 9s
Test / test (3.11) (pull_request) Successful in 51s
contracts-codegen-drift / schedule accepts both cron spellings (pull_request) Successful in 19s
registry-ssot-drift / registry-ssot-drift (pull_request) Successful in 12s
Test / test (3.12) (pull_request) Successful in 54s
Test / test (3.13) (pull_request) Successful in 55s
Test / all-required (pull_request) Successful in 1s
llm-registry-ssot-drift / llm-registry-ssot-conformance (pull_request) Successful in 19s
contracts-codegen-drift / plugin-install-report-conformance (pull_request) Successful in 19s
registry-ssot-drift / registry-ssot-conformance (pull_request) Successful in 18s
contracts-codegen-drift / bindings-parity (pull_request) Successful in 22s
contracts-codegen-drift / all-required (pull_request) Successful in 2s
harden(publish): port the release-line ancestry gate + its test to main
The 0.5.6 maintenance release hardened publish-sdk.yml on `release/0.5.x` only,
which left the two lines DRIFTED: a future release cut from main would run the
older main-only gate, and main carried no test for it at all.

The workflow that runs for a tag is the one in the TAG's own tree, so this drift
is not cosmetic — whichever line a release is cut from decides which gate
version enforces it. Porting keeps a single behaviour regardless of origin.

Cherry-pick of 91701fe (release/0.5.x) with no changes:

  * ancestry proven against origin/main OR the protected
    origin/release/<major>.<minor>.x line for the tag's OWN minor version, so a
    maintenance branch can never publish outside its line;
  * tests/test_publish_release_ancestry_gate.py, which extracts the gate's shell
    VERBATIM from the YAML and runs accept AND reject arms against real
    throwaway repos — main-ancestor accepts, release-line accepts its own minor,
    and foreign-minor / unreviewed-branch / nonexistent-line / missing-origin
    all reject (no fail-open).

Re-verified after the cherry-pick against main's copy of the workflow (which had
diverged): 6/6 pass. The reject arms were mutation-checked on the release line —
removing the `exit 1` turns 2 tests red.

Prerequisite unchanged and documented in the header: every
release/<major>.<minor>.x branch must carry main-equivalent branch protection.
release/0.5.x does today; an unprotected release/* branch would be a publish
bypass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:46:42 -07:00

731 lines
34 KiB
YAML

name: publish-sdk
# Mirrors molecule-ai-workspace-runtime/.gitea/workflows/publish-runtime.yml.
# Publishes the molecule-ai-sdk wheel + sdist to the private Gitea PyPI
# registry. Triggered by a `sdk-v*` tag (repo-prefixed to avoid colliding with
# the runtime's `runtime-v*` tags in the shared org).
#
# AUTH: there is NO package-write Gitea Actions secret by design. The Gitea
# package-publisher token (MOL_PACKAGE_TOKEN, a PAT with `write:package` owned by
# the `pypi-publisher` persona) is sourced from the Infisical SSOT
# (https://key.moleculesai.app, prod env, /ci/gitea-pypi-publisher) via the CI
# machine identity, exactly as the runtime does.
#
# EXTERNAL TRUST PREREQUISITE: Gitea must protect the `sdk-v*` tag namespace so
# tags can only be created from reviewed history by release maintainers.
# A workflow cannot enforce the trustworthiness of the tag that supplied its
# own YAML; repository-admin tag protection is the root release gate.
#
# SECOND PREREQUISITE (maintenance lines): every `release/<major>.<minor>.x`
# branch MUST carry the same repo-admin branch protection as `main` - PR-only
# (no direct push), >=1 approval, all status checks required. The ancestry gate
# below accepts such a branch as reviewed history, and a workflow cannot verify
# branch protection on its own, so that protection IS the guarantee. An
# unprotected release/* branch would be a publish bypass.
#
# RUNNER TRUST PREREQUISITE: the `publish` label is reserved for release jobs
# and must create a fresh Docker job container for every invocation. It must
# never resolve to a persistent host executor or a runner shared with PR jobs.
on:
push:
tags:
- "sdk-v*"
permissions:
contents: read
concurrency:
group: publish-sdk
cancel-in-progress: false
env:
GITEA_URL: https://git.moleculesai.app
GITEA_PYPI_SIMPLE_URL: https://git.moleculesai.app/api/packages/molecule-ai/pypi/simple/
GITEA_PYPI_UPLOAD_URL: https://git.moleculesai.app/api/packages/molecule-ai/pypi/
jobs:
build:
if: startsWith(github.ref, 'refs/tags/sdk-v')
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
version: ${{ steps.version.outputs.version }}
wheel_sha256: ${{ steps.artifact_hashes.outputs.wheel_sha256 }}
sdist_sha256: ${{ steps.artifact_hashes.outputs.sdist_sha256 }}
wheel_filename: ${{ steps.artifact_hashes.outputs.wheel_filename }}
sdist_filename: ${{ steps.artifact_hashes.outputs.sdist_filename }}
requirements_sha256: ${{ steps.artifact_hashes.outputs.requirements_sha256 }}
provenance_sha256: ${{ steps.artifact_hashes.outputs.provenance_sha256 }}
source_date_epoch: ${{ steps.version.outputs.source_date_epoch }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
cache: pip
- name: Require release commit to be reviewed history (main or its release line)
run: |
set -eu
# A release tag may be cut from EITHER reviewed, PR-only history:
# * origin/main - the normal path, and
# * origin/release/<major>.<minor>.x - the maintenance line for the
# tag's OWN minor version, for a backport that must not drag main's
# unreleased work into every consumer that pins this SDK.
# The line is derived FROM THE TAG, so sdk-v0.5.6 can only be proven
# against release/0.5.x - a maintenance branch can never publish a
# version outside its own minor line.
proven=""
if git show-ref --verify --quiet refs/remotes/origin/main && git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main; then
proven="origin/main"
else
version="${GITHUB_REF#refs/tags/sdk-v}"
line="release/$(echo "$version" | cut -d. -f1,2).x"
if ! git show-ref --verify --quiet "refs/remotes/origin/$line"; then
echo "::error::neither origin/main nor origin/$line contains this commit (or they were not fetched); cannot prove release ancestry"
exit 1
fi
if git merge-base --is-ancestor "$GITHUB_SHA" "refs/remotes/origin/$line"; then
proven="origin/$line"
fi
fi
if [ -z "$proven" ]; then
echo "::error::tag commit is not contained in reviewed history (origin/main, or the protected origin/release/<major>.<minor>.x line for this tag)"
exit 1
fi
echo "release ancestry proven against $proven"
- name: Derive version + stamp pyproject (tag is source of truth)
id: version
# TAG source of truth (mirrors runtime): `main` is PR-only (protected),
# so we do NOT commit a release-only version bump. The `sdk-v*` tag is
# authoritative: we STAMP its version into pyproject.toml at build time
# (ephemeral, in-CI — NEVER committed back) so the wheel carries the
# correct version by construction. Recovery uses Gitea's rerun action
# on the original tag-triggered run; branch workflow dispatch is
# intentionally unavailable to this credential-bearing workflow.
run: |
set -eu
STAMP_FROM_TAG=0
if echo "$GITHUB_REF" | grep -q "^refs/tags/sdk-v"; then
VERSION="${GITHUB_REF#refs/tags/sdk-v}"
STAMP_FROM_TAG=1
else
VERSION=$(python - <<'PY'
import tomllib
with open("pyproject.toml", "rb") as f:
print(tomllib.load(f)["project"]["version"])
PY
)
fi
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([a-z0-9.+-]+)?$'; then
echo "::error::version $VERSION does not look like a PEP 440 release"
exit 1
fi
if [ "$STAMP_FROM_TAG" = "1" ]; then
# Ephemeral, in-CI stamp — NOT committed back to the repo. After this the
# built wheel/sdist carry $VERSION regardless of what main's pyproject says.
VERSION="$VERSION" python - <<'PY'
import os, re
version = os.environ["VERSION"]
with open("pyproject.toml", "r", encoding="utf-8") as f:
text = f.read()
new, n = re.subn(
r'(?m)^(version\s*=\s*)"[^"]+"',
r'\g<1>"' + version + '"',
text,
count=1,
)
if n != 1:
raise SystemExit('could not locate a single version = "..." line in pyproject.toml')
with open("pyproject.toml", "w", encoding="utf-8") as f:
f.write(new)
print(f"stamped pyproject [project].version = {version} (ephemeral build-time)")
PY
fi
PYPROJECT_VERSION=$(python - <<'PY'
import tomllib
with open("pyproject.toml", "rb") as f:
print(tomllib.load(f)["project"]["version"])
PY
)
# After the stamp this is trivially equal; we keep the read as a sanity
# assertion (catches a botched stamp) rather than a policy gate on main.
if [ "$VERSION" != "$PYPROJECT_VERSION" ]; then
echo "::error::after stamp, build version $VERSION != pyproject $PYPROJECT_VERSION (stamp failed?)"
exit 1
fi
SOURCE_DATE_EPOCH=$(git show -s --format=%ct "$GITHUB_SHA")
case "$SOURCE_DATE_EPOCH" in
''|*[!0-9]*)
echo "::error::could not derive SOURCE_DATE_EPOCH from tagged commit $GITHUB_SHA"
exit 1
;;
esac
if [ "$SOURCE_DATE_EPOCH" -le 0 ]; then
echo "::error::tagged commit has invalid timestamp $SOURCE_DATE_EPOCH"
exit 1
fi
echo "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" >> "$GITHUB_ENV"
{
echo "version=$VERSION"
echo "source_date_epoch=$SOURCE_DATE_EPOCH"
} >> "$GITHUB_OUTPUT"
echo "Publishing molecule-ai-sdk $VERSION to Gitea package registry"
- name: Build wheel + sdist
run: |
set -eu
python -m pip install --require-hashes -r release-requirements.txt
python -m build --no-isolation
set -- dist/*.tar.gz
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
echo "::error::expected exactly one sdist to normalize"
exit 1
fi
python tools/normalize_sdist.py --epoch "$SOURCE_DATE_EPOCH" "$1"
python -m twine check dist/*
# SUPPLY-CHAIN GUARD — stop registry shadowing at the source. The
# build must emit ONLY molecule-ai-sdk's wheel + sdist, never a
# dependency closure. This identity is deliberately hardcoded rather
# than derived from mutable project metadata: the publisher token can
# write multiple org packages, so a renamed artifact must fail before
# credentials are exposed.
DIST_NAME=molecule_ai_sdk
unexpected=""
for artifact in dist/*; do
filename=${artifact#dist/}
case "$filename" in
"${DIST_NAME}-"*.whl|"${DIST_NAME}-"*.tar.gz) ;;
*) unexpected="${unexpected}${filename}\n" ;;
esac
done
if [ -n "$unexpected" ]; then
echo "::error::dist/ holds non-package artifacts -- a dependency closure here would shadow public PyPI packages in our registry. Refusing to publish:"
echo "$unexpected"
exit 1
fi
echo "dist/ contains only ${DIST_NAME} wheel + sdist (no dependency closure)"
- name: Capture artifact SHA256
id: artifact_hashes
env:
SDK_VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
set -- dist/*.whl
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
echo "::error::expected exactly one wheel in dist/"
exit 1
fi
WHEEL=$1
set -- dist/*.tar.gz
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
echo "::error::expected exactly one sdist in dist/"
exit 1
fi
SDIST=$1
WHEEL_HASH=$(sha256sum "$WHEEL" | awk '{print $1}')
SDIST_HASH=$(sha256sum "$SDIST" | awk '{print $1}')
REQUIREMENTS_HASH=$(sha256sum release-requirements.txt | awk '{print $1}')
{
echo "schema=1"
echo "repository=$GITHUB_REPOSITORY"
echo "commit_sha=$GITHUB_SHA"
echo "ref=$GITHUB_REF"
echo "version=$SDK_VERSION"
echo "source_date_epoch=$SOURCE_DATE_EPOCH"
echo "wheel_filename=$(basename "$WHEEL")"
echo "wheel_sha256=$WHEEL_HASH"
echo "sdist_filename=$(basename "$SDIST")"
echo "sdist_sha256=$SDIST_HASH"
echo "requirements_filename=release-requirements.txt"
echo "requirements_sha256=$REQUIREMENTS_HASH"
} > release-provenance.env
PROVENANCE_HASH=$(sha256sum release-provenance.env | awk '{print $1}')
{
echo "wheel_sha256=$WHEEL_HASH"
echo "sdist_sha256=$SDIST_HASH"
echo "wheel_filename=$(basename "$WHEEL")"
echo "sdist_filename=$(basename "$SDIST")"
echo "requirements_sha256=$REQUIREMENTS_HASH"
echo "provenance_sha256=$PROVENANCE_HASH"
} >> "$GITHUB_OUTPUT"
echo "Wheel: $(basename "$WHEEL")"
echo "Wheel SHA256: $WHEEL_HASH"
echo "Sdist: $(basename "$SDIST")"
echo "Sdist SHA256: $SDIST_HASH"
- name: Upload exact release artifacts
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: sdk-release-${{ github.sha }}
path: |
dist/*.whl
dist/*.tar.gz
release-provenance.env
release-requirements.txt
if-no-files-found: error
retention-days: 1
publish:
if: startsWith(github.ref, 'refs/tags/sdk-v')
needs: build
runs-on: publish
timeout-minutes: 20
steps:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Download exact release artifacts
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: sdk-release-${{ github.sha }}
path: ${{ runner.temp }}/sdk-release
- name: Verify transferred artifact hashes
env:
EXPECTED_WHEEL_SHA256: ${{ needs.build.outputs.wheel_sha256 }}
EXPECTED_SDIST_SHA256: ${{ needs.build.outputs.sdist_sha256 }}
EXPECTED_WHEEL_FILENAME: ${{ needs.build.outputs.wheel_filename }}
EXPECTED_SDIST_FILENAME: ${{ needs.build.outputs.sdist_filename }}
EXPECTED_REQUIREMENTS_SHA256: ${{ needs.build.outputs.requirements_sha256 }}
EXPECTED_PROVENANCE_SHA256: ${{ needs.build.outputs.provenance_sha256 }}
EXPECTED_VERSION: ${{ needs.build.outputs.version }}
EXPECTED_SOURCE_DATE_EPOCH: ${{ needs.build.outputs.source_date_epoch }}
TRUSTED_REQUIREMENTS_SHA256: 1920b877523bf58e365f6fd70c42c9a780f774b36be3b752862120e6c997b9dd
run: |
set -eu
RELEASE_ROOT="$RUNNER_TEMP/sdk-release"
MAX_RELEASE_ARTIFACT_BYTES=10485760
cd "$RUNNER_TEMP"
if [ -n "$(find "$RELEASE_ROOT" -type l -print -quit)" ]; then
echo "::error::release artifact transfer contains a symlink"
exit 1
fi
FILE_COUNT=$(find "$RELEASE_ROOT" -type f | wc -l | tr -d ' ')
if [ "$FILE_COUNT" -ne 4 ]; then
echo "::error::release transfer must contain wheel, sdist, provenance, and requirements only"
exit 1
fi
PROVENANCE="$RELEASE_ROOT/release-provenance.env"
if [ ! -f "$PROVENANCE" ]; then
echo "::error::release provenance manifest is missing"
exit 1
fi
ACTUAL_PROVENANCE_SHA256=$(sha256sum "$PROVENANCE" | awk '{print $1}')
if [ "$ACTUAL_PROVENANCE_SHA256" != "$EXPECTED_PROVENANCE_SHA256" ]; then
echo "::error::transferred release provenance hash mismatch"
exit 1
fi
if [ "$(wc -l < "$PROVENANCE" | tr -d ' ')" -ne 12 ]; then
echo "::error::release provenance has unexpected fields"
exit 1
fi
require_provenance() {
if ! grep -Fqx -- "$1" "$PROVENANCE"; then
echo "::error::release provenance mismatch for ${1%%=*}"
exit 1
fi
}
require_provenance "schema=1"
require_provenance "repository=$GITHUB_REPOSITORY"
require_provenance "commit_sha=$GITHUB_SHA"
require_provenance "ref=$GITHUB_REF"
require_provenance "version=$EXPECTED_VERSION"
require_provenance "source_date_epoch=$EXPECTED_SOURCE_DATE_EPOCH"
require_provenance "wheel_filename=$EXPECTED_WHEEL_FILENAME"
require_provenance "wheel_sha256=$EXPECTED_WHEEL_SHA256"
require_provenance "sdist_filename=$EXPECTED_SDIST_FILENAME"
require_provenance "sdist_sha256=$EXPECTED_SDIST_SHA256"
require_provenance "requirements_filename=release-requirements.txt"
if [ "$EXPECTED_REQUIREMENTS_SHA256" != "$TRUSTED_REQUIREMENTS_SHA256" ]; then
echo "::error::build reported an untrusted publisher requirements hash"
exit 1
fi
require_provenance "requirements_sha256=$TRUSTED_REQUIREMENTS_SHA256"
ACTUAL_REQUIREMENTS_SHA256=$(sha256sum "$RELEASE_ROOT/release-requirements.txt" | awk '{print $1}')
if [ "$ACTUAL_REQUIREMENTS_SHA256" != "$TRUSTED_REQUIREMENTS_SHA256" ]; then
echo "::error::transferred publisher requirements hash mismatch"
exit 1
fi
verify_transferred_artifact() {
label=$1
filename=$2
expected_hash=$3
case "$filename" in
''|*/*)
echo "::error::invalid transferred $label filename"
exit 1
;;
esac
artifact="$RELEASE_ROOT/dist/$filename"
if [ ! -f "$artifact" ]; then
echo "::error::expected transferred $label not found: $filename"
exit 1
fi
artifact_size=$(wc -c < "$artifact" | tr -d ' ')
if [ "$artifact_size" -le 0 ] || [ "$artifact_size" -gt "$MAX_RELEASE_ARTIFACT_BYTES" ]; then
echo "::error::transferred $label size $artifact_size is outside the allowed range"
exit 1
fi
actual_hash=$(sha256sum "$artifact" | awk '{print $1}')
if [ "$actual_hash" != "$expected_hash" ]; then
echo "::error::transferred $label hash $actual_hash != build $expected_hash"
exit 1
fi
}
case "$EXPECTED_WHEEL_FILENAME" in
"molecule_ai_sdk-$EXPECTED_VERSION-"*.whl) ;;
*) echo "::error::unexpected SDK wheel filename: $EXPECTED_WHEEL_FILENAME"; exit 1 ;;
esac
if [ "$EXPECTED_SDIST_FILENAME" != "molecule_ai_sdk-$EXPECTED_VERSION.tar.gz" ]; then
echo "::error::unexpected SDK sdist filename: $EXPECTED_SDIST_FILENAME"
exit 1
fi
verify_transferred_artifact wheel "$EXPECTED_WHEEL_FILENAME" "$EXPECTED_WHEEL_SHA256"
verify_transferred_artifact sdist "$EXPECTED_SDIST_FILENAME" "$EXPECTED_SDIST_SHA256"
- name: Install hash-locked publisher tooling
run: |
set -eu
RELEASE_ROOT="$RUNNER_TEMP/sdk-release"
PUBLISHER_VENV="$RUNNER_TEMP/sdk-publisher-venv"
cd "$RUNNER_TEMP"
python -m venv "$PUBLISHER_VENV"
"$PUBLISHER_VENV/bin/python" -I -m pip install --no-cache-dir \
--only-binary=:all: --require-hashes \
-r "$RELEASE_ROOT/release-requirements.txt"
"$PUBLISHER_VENV/bin/python" -I -m twine --version
- name: Validate release artifact identity
env:
SDK_VERSION: ${{ needs.build.outputs.version }}
EXPECTED_WHEEL_FILENAME: ${{ needs.build.outputs.wheel_filename }}
EXPECTED_SDIST_FILENAME: ${{ needs.build.outputs.sdist_filename }}
run: |
set -eu
RELEASE_ROOT="$RUNNER_TEMP/sdk-release"
PUBLISHER_VENV="$RUNNER_TEMP/sdk-publisher-venv"
cd "$RUNNER_TEMP"
ARTIFACT_ROOT="$RELEASE_ROOT/dist" \
"$PUBLISHER_VENV/bin/python" -I - <<'PY'
import os
from pathlib import Path
from twine.package import PackageFile
expected_name = "molecule-ai-sdk"
expected_version = os.environ["SDK_VERSION"]
artifact_root = Path(os.environ["ARTIFACT_ROOT"])
artifacts = (
(os.environ["EXPECTED_WHEEL_FILENAME"], "bdist_wheel"),
(os.environ["EXPECTED_SDIST_FILENAME"], "sdist"),
)
for filename, expected_type in artifacts:
path = artifact_root / filename
package = PackageFile.from_filename(str(path), comment=None)
if package.metadata["name"] != expected_name:
raise SystemExit(
f"artifact {filename} metadata Name is not {expected_name}"
)
if package.safe_name != expected_name:
raise SystemExit(
f"artifact {filename} canonical name is not {expected_name}"
)
if package.version != expected_version:
raise SystemExit(
f"artifact {filename} version {package.version} != {expected_version}"
)
if package.filetype != expected_type:
raise SystemExit(
f"artifact {filename} type {package.filetype} != {expected_type}"
)
print(
f"Validated {filename}: {expected_name} "
f"{expected_version} ({expected_type})"
)
PY
# KMS migration (Gitea-Actions-secrets -> Infisical/KMS): source the
# canonical Gitea package-publisher token from the Infisical SSOT via the
# CI machine identity, mirroring the runtime's proven REST-fetch pattern.
# The consolidated least-privilege credential is MOL_PACKAGE_TOKEN (a Gitea
# PAT with `write:package` scope owned by the `pypi-publisher` persona) in
# the prod /ci/gitea-pypi-publisher folder. Fetch and consumption happen
# in one step; no credential is persisted across a cancellation boundary.
#
# POSITIVE CONTROL: Infisical is the AUTHORITATIVE source for this
# credential, so this step uses `set -uo pipefail`, a null-safe JSON
# extractor, and an explicit non-empty assertion to HARD-FAIL on a
# broken/empty/404/auth-failure fetch. We do NOT fall back to a Gitea
# secret here: a broken authoritative fetch must surface loudly rather
# than be silently masked.
- name: Fetch publisher credential and publish
env:
INFISICAL_CI_CLIENT_ID: ${{ secrets.INFISICAL_CI_CLIENT_ID }}
INFISICAL_CI_CLIENT_SECRET: ${{ secrets.INFISICAL_CI_CLIENT_SECRET }}
INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_CI_PROJECT_ID }}
SDK_VERSION: ${{ needs.build.outputs.version }}
EXPECTED_WHEEL_FILENAME: ${{ needs.build.outputs.wheel_filename }}
EXPECTED_SDIST_FILENAME: ${{ needs.build.outputs.sdist_filename }}
run: |
set -euo pipefail
RELEASE_ROOT="$RUNNER_TEMP/sdk-release"
PUBLISHER_VENV="$RUNNER_TEMP/sdk-publisher-venv"
cd "$RUNNER_TEMP"
# setup-python's Linux CPython is dynamically linked against the
# private libpython in its tool-cache prefix. Select only that exact
# directory instead of forwarding the runner's full loader search
# path into the secret-bearing Twine process.
select_publisher_loader_path() {
if ! candidate_python=$(readlink -f "$1"); then
echo "::error::could not resolve the publisher Python executable" >&2
return 1
fi
if [ ! -x "$candidate_python" ]; then
echo "::error::resolved publisher Python is not executable" >&2
return 1
fi
candidate_prefix=$(dirname "$(dirname "$candidate_python")")
candidate_lib="$candidate_prefix/lib"
case "$candidate_lib" in
/*) ;;
*)
echo "::error::publisher Python library directory is not absolute" >&2
return 1
;;
esac
case "$candidate_lib" in
*:*|*$'\n'*|*$'\r'*)
echo "::error::publisher Python library directory contains an unsafe character" >&2
return 1
;;
esac
if [ ! -d "$candidate_lib" ]; then
echo "::error::publisher Python library directory does not exist" >&2
return 1
fi
case ":${LD_LIBRARY_PATH:-}:" in
*":$candidate_lib:"*) ;;
*)
echo "::error::LD_LIBRARY_PATH does not contain the publisher Python library directory" >&2
return 1
;;
esac
printf '%s\n' "$candidate_lib"
}
PUBLISHER_LD_LIBRARY_PATH=$(select_publisher_loader_path "$PUBLISHER_VENV/bin/python")
BASE="https://key.moleculesai.app"
if [ -z "${INFISICAL_CI_CLIENT_ID:-}" ] || [ -z "${INFISICAL_CI_CLIENT_SECRET:-}" ] || [ -z "${INFISICAL_PROJECT_ID:-}" ]; then
echo "::error::Infisical CI machine identity (INFISICAL_CI_CLIENT_ID / INFISICAL_CI_CLIENT_SECRET / INFISICAL_CI_PROJECT_ID) is not set -- cannot fetch publisher creds from the SSOT"
exit 1
fi
TOK=$("$PUBLISHER_VENV/bin/python" -I -c 'import json,os,sys; json.dump({"clientId": os.environ["INFISICAL_CI_CLIENT_ID"], "clientSecret": os.environ["INFISICAL_CI_CLIENT_SECRET"]}, sys.stdout)' \
| curl -fsS -A curl/8.4.0 -X POST "$BASE/api/v1/auth/universal-auth/login" \
-H 'Content-Type: application/json' --data-binary @- \
| "$PUBLISHER_VENV/bin/python" -I -c 'import sys,json; d=json.load(sys.stdin); v=d.get("accessToken"); print(v if isinstance(v,str) and v else "")')
if [ -z "${TOK}" ]; then
echo "::error::Infisical universal-auth login returned no accessToken -- authoritative fetch cannot proceed"
exit 1
fi
echo "::add-mask::$TOK"
# Null-safe extractor: never KeyErrors; prints empty string when the
# secret (or any nesting level) is missing, so the [ -z ] guard below
# is the single fail-closed decision point.
read_secret() {
printf 'header = "Authorization: Bearer %s"\n' "$TOK" \
| curl --config - -fsS -A curl/8.4.0 \
"$BASE/api/v3/secrets/raw/$1?workspaceId=$INFISICAL_PROJECT_ID&environment=prod&secretPath=$2" \
| "$PUBLISHER_VENV/bin/python" -I -c 'import sys,json; d=json.load(sys.stdin); v=(d.get("secret") or {}).get("secretValue"); print(v if isinstance(v,str) and v else "")'
}
MOL_PACKAGE_TOKEN=$(read_secret MOL_PACKAGE_TOKEN %2Fci%2Fgitea-pypi-publisher)
unset INFISICAL_CI_CLIENT_ID INFISICAL_CI_CLIENT_SECRET INFISICAL_PROJECT_ID TOK
if [ -z "${MOL_PACKAGE_TOKEN}" ]; then
echo "::error::Infisical returned empty for MOL_PACKAGE_TOKEN at /ci/gitea-pypi-publisher (prod) -- authoritative fetch failed; failing loudly"
exit 1
fi
echo "::add-mask::$MOL_PACKAGE_TOKEN"
PUBLISH_TMP=$(mktemp -d "$RUNNER_TEMP/sdk-publish.XXXXXX")
TWINE_HOME="$PUBLISH_TMP/twine-home"
COMPARE_ROOT="$PUBLISH_TMP/registry"
mkdir -m 700 "$TWINE_HOME" "$COMPARE_ROOT"
trap 'rm -rf "$PUBLISH_TMP"; unset MOL_PACKAGE_TOKEN' EXIT
echo "Gitea package-publisher token loaded from Infisical"
# Canonical least-privilege publisher identity (write:package). The
# Gitea package registry authenticates the token; the username is the
# token owner (`pypi-publisher`).
PUBLISHER_USER="pypi-publisher"
if [ -z "${MOL_PACKAGE_TOKEN:-}" ]; then
echo "::error::Gitea package registry publisher token is not set (Infisical fetch step did not export it)"
exit 1
fi
# Idempotent publish: a re-run of this workflow must not hard-fail on the
# already-published version. twine --skip-existing is NOT usable here --
# twine hard-rejects it for the Gitea registry ("UnsupportedConfiguration").
# Instead download the exact registry filename for every dist file and
# skip only when its bytes match this reproducible build.
FILES_BASE="${GITEA_PYPI_UPLOAD_URL%/}/files"
# Iterate only the two filenames attested by the secret-free build
# job. Never reconstruct cross-job state or publish a wildcard.
artifact_files=(
"$RELEASE_ROOT/dist/$EXPECTED_WHEEL_FILENAME"
"$RELEASE_ROOT/dist/$EXPECTED_SDIST_FILENAME"
)
missing_files=()
for f in "${artifact_files[@]}"; do
[ -e "$f" ] || { echo "::error::expected artifact not found: $f"; exit 1; }
base=$(basename "$f")
registry_file="$COMPARE_ROOT/$base"
registry_url="$FILES_BASE/molecule-ai-sdk/$SDK_VERSION/$base"
if ! registry_status=$(printf 'user = "%s:%s"\n' "$PUBLISHER_USER" "$MOL_PACKAGE_TOKEN" \
| curl --config - -sS -A curl/8.4.0 --proto '=https' \
-o "$registry_file" -w '%{http_code}' "$registry_url"); then
echo "::error::could not query exact registry artifact $base"
exit 1
fi
case "$registry_status" in
200)
local_hash=$(sha256sum "$f" | awk '{print $1}')
registry_hash=$(sha256sum "$registry_file" | awk '{print $1}')
if [ "$registry_hash" != "$local_hash" ]; then
echo "::error::registry artifact hash mismatch for $base: registry=$registry_hash build=$local_hash"
exit 1
fi
echo "::notice::$base already in registry and byte-identical"
;;
404) missing_files+=("$f") ;;
*)
echo "::error::registry returned HTTP $registry_status for exact artifact $base"
exit 1
;;
esac
done
if [ "${#missing_files[@]}" -eq 0 ]; then
echo "::notice::all dist files already published (re-run) -- skipping upload"
else
(
cd "$PUBLISH_TMP"
env -i \
HOME="$TWINE_HOME" \
LANG=C.UTF-8 \
LD_LIBRARY_PATH="$PUBLISHER_LD_LIBRARY_PATH" \
PYTHONNOUSERSITE=1 \
TWINE_DISABLE_PROGRESS_BAR=1 \
TWINE_NON_INTERACTIVE=1 \
TWINE_REPOSITORY_URL="$GITEA_PYPI_UPLOAD_URL" \
TWINE_USERNAME="$PUBLISHER_USER" \
TWINE_PASSWORD="$MOL_PACKAGE_TOKEN" \
"$PUBLISHER_VENV/bin/python" -I -m twine upload "${missing_files[@]}"
)
fi
rm -rf "$PUBLISH_TMP"
unset MOL_PACKAGE_TOKEN
unset PUBLISH_TMP TWINE_HOME COMPARE_ROOT
trap - EXIT
verify:
if: startsWith(github.ref, 'refs/tags/sdk-v')
needs: [build, publish]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Verify install from Gitea registry
env:
SDK_VERSION: ${{ needs.build.outputs.version }}
EXPECTED_WHEEL_SHA256: ${{ needs.build.outputs.wheel_sha256 }}
EXPECTED_SDIST_SHA256: ${{ needs.build.outputs.sdist_sha256 }}
EXPECTED_WHEEL_FILENAME: ${{ needs.build.outputs.wheel_filename }}
EXPECTED_SDIST_FILENAME: ${{ needs.build.outputs.sdist_filename }}
run: |
set -eu
cd "$RUNNER_TEMP"
SMOKE_ROOT=$(mktemp -d "$RUNNER_TEMP/sdk-smoke.XXXXXX")
python -m venv "$SMOKE_ROOT/venv"
PIP="$SMOKE_ROOT/venv/bin/pip"
PY="$SMOKE_ROOT/venv/bin/python"
CLI="$SMOKE_ROOT/venv/bin/molecule-plugin"
$PIP install --upgrade --quiet pip
# Download both exact registry artifacts and prove byte identity with
# this build before installing the local wheel. Public dependencies
# are still resolved solely from PyPI.
DOWNLOAD_DIR="$SMOKE_ROOT/download"
mkdir "$DOWNLOAD_DIR"
FILES_BASE="${GITEA_PYPI_UPLOAD_URL%/}/files/molecule-ai-sdk/$SDK_VERSION"
verify_registry_artifact() {
label=$1
filename=$2
expected_hash=$3
case "$filename" in
''|*/*)
echo "::error::invalid expected $label filename"
exit 1
;;
esac
target="$DOWNLOAD_DIR/$filename"
curl -fsS -A curl/8.4.0 --proto '=https' \
"$FILES_BASE/$filename" -o "$target"
downloaded_hash=$(sha256sum "$target" | awk '{print $1}')
if [ "$downloaded_hash" != "$expected_hash" ]; then
echo "::error::registry $label hash $downloaded_hash != built $label $expected_hash"
exit 1
fi
echo "Verified registry $label: $filename"
}
verify_registry_artifact wheel "$EXPECTED_WHEEL_FILENAME" "$EXPECTED_WHEEL_SHA256"
verify_registry_artifact sdist "$EXPECTED_SDIST_FILENAME" "$EXPECTED_SDIST_SHA256"
SDK_WHEEL="$DOWNLOAD_DIR/$EXPECTED_WHEEL_FILENAME"
$PIP install \
--index-url "https://pypi.org/simple/" \
--no-cache-dir "$SDK_WHEEL"
INSTALLED=$($PIP show molecule-ai-sdk | awk -F': ' '/^Version:/{print $2}')
if [ "$INSTALLED" != "$SDK_VERSION" ]; then
echo "::error::installed $INSTALLED, want $SDK_VERSION"
exit 1
fi
# Import the runtime-facing packages to prove the wheel installs and
# imports cleanly. We deliberately do NOT import
# molecule_plugin.adapter_conformance here: it does a module-level
# `import pytest` (it ships a conformance-test base class), and pytest
# is a TEST dependency that is not installed in this verify venv. The
# verify step's job is to confirm the wheel installs + the
# runtime-facing packages import — not to drag in a test dependency
# just to prove a conformance-test base class ships.
SMOKE=$(mktemp -d)
cd "$SMOKE"
# Every SCAFFOLD_KINDS entry must be exercised here — 0.5.2 shipped with
# `trigger` missing from this loop, so a wheel whose trigger template was
# incomplete still verified green. tests/test_plugin_release_workflow.py
# asserts this loop covers molecule_plugin.scaffold.SCAFFOLD_KINDS.
for kind in skill mcp channel trigger enterskill; do
"$CLI" init "registry-$kind" --kind "$kind"
"$CLI" validate plugin "registry-$kind"
done
"$CLI" channel-sdk check "registry-channel"
"$CLI" channel-sdk check "registry-trigger"
$PY -c "from molecule_plugin import AgentskillsAdaptor, validate_plugin; from molecule_external_workspace import RemoteAgentClient; print('sdk registry smoke OK')"
- name: Publish summary
if: always()
run: |
{
echo "## publish-sdk $(date -u +%FT%TZ)"
echo
echo "**Version:** \`${{ needs.build.outputs.version }}\`"
echo "**Registry:** \`$GITEA_PYPI_SIMPLE_URL\`"
echo "**Wheel SHA256:** \`${{ needs.build.outputs.wheel_sha256 }}\`"
echo "**Sdist SHA256:** \`${{ needs.build.outputs.sdist_sha256 }}\`"
echo
echo "PyPI is intentionally not part of this release path."
} >> "$GITHUB_STEP_SUMMARY"