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
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>
731 lines
34 KiB
YAML
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"
|