ci(gitea): port 7 reusable workflows to .gitea/workflows/ (RFC #229 P1-1) #7

Merged
claude-ceo-assistant merged 1 commits from feat/gitea-workflows-port into main 2026-05-10 11:09:43 +00:00
7 changed files with 1430 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
# Gitea Actions port of .github/workflows/auto-promote-branch.yml
# Ported 2026-05-10 per RFC #229 P1-1.
# Caller migration: update `uses: molecule-ai/molecule-ci/.github/workflows/...@ref`
# → `uses: molecule-ai/molecule-ci/.gitea/workflows/...@ref` in your consuming repo.
# Both .github/ and .gitea/ versions exist transitionally for safe consumer migration.
#
# CROSS-REPO INVOCATION CAVEAT (Gitea 1.22.6):
# Per memory feedback_gitea_cross_repo_uses_blocked + audit-force-merge composite
# rationale: cross-repo `uses: org/repo/...@ref` (workflow_call form) does NOT
# resolve on Gitea 1.22.6 because [actions].DEFAULT_ACTIONS_URL=github routes the
# fetch to github.com (where `molecule-ai` is suspended). This file is a port,
# not a switch-on. Until the operator-host server-side flip lands (task #109
# actions/* mirror) consumers calling this via `uses:` will continue to no-op
# /404 even after switching to the .gitea/ path. Land this PR first; flip the
# server config + consumers in a follow-up batch.
name: Auto-promote branch (reusable)
# Reusable version of the auto-promote-staging workflow that lived
# directly in molecule-ci. Any repo with a `from-branch` (typically
# `staging`) → `to-branch` (typically `main`) flow can call this
# workflow to fast-forward `to-branch` whenever `from-branch` is
# strictly ahead AND all configured required-status-checks on the
# `from-branch` HEAD are green.
#
# Adoption pattern in a consumer repo:
#
# # .github/workflows/auto-promote.yml
# name: Auto-promote staging → main
# on:
# push:
# branches: [staging]
# workflow_dispatch:
# permissions:
# contents: write # push the fast-forward to to-branch
# statuses: read # read commit status checks
# administration: read # read branch protection (REQUIRED — see below)
# jobs:
# promote:
# uses: molecule-ai/molecule-ci/.github/workflows/auto-promote-branch.yml@v1
# with:
# from-branch: staging
# to-branch: main
#
# Repo-agnostic by design — gates are read from the consuming repo's
# branch protection at run time, not hardcoded here.
#
# `@v1` is a moving tag pointing at the latest 1.x release of
# molecule-ci's reusable workflows (GitHub Actions convention, same
# as `actions/checkout@v4`). Breaking changes get a new `@v2` tag
# and the old `@v1` keeps working for existing consumers. Pinning to
# `@main` is also accepted for forward-compat preview but is
# unstable — any change merged here rolls out instantly to consumers
# without a release boundary.
#
# `administration: read` is REQUIRED. Without it, the branch-protection
# API returns 403 and the workflow refuses to fast-forward (fail-loud),
# rather than silently degrading to --ff-only-only enforcement (which
# is ancestry-only, not test-status — a green-but-flaky branch would
# ff-promote red commits). If you intentionally want no-gate
# enforcement, leave from-branch unprotected — a 404 from the API is
# treated as "no gates configured" and falls back to --ff-only safety.
#
# Excluded-by-policy repos (molecule-core + molecule-controlplane per
# CEO directive 2026-04-24) simply do not adopt this workflow; the
# reusable shape adds no surface area to repos that don't call it.
on:
workflow_call:
inputs:
from-branch:
description: "Source branch with green CI"
required: false
default: staging
type: string
to-branch:
description: "Target branch to fast-forward"
required: false
default: main
type: string
permissions:
contents: write
statuses: read
jobs:
promote:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Check required gates (if configured) on source HEAD
id: gates
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.sha }}
FROM_BRANCH: ${{ inputs.from-branch }}
shell: bash
run: |
set -euo pipefail
# Read required gates from branch protection. Three response
# classes, distinguished by HTTP status:
#
# 200 — branch protection is configured. Honor the gates.
# 404 — branch is not protected. Legitimate "no gates";
# fall back to --ff-only as the sole safety net.
# 403 — caller's GITHUB_TOKEN can't read branch protection.
# FAIL LOUD. The previous behavior conflated this
# with 404 ("api inaccessible") and silently degraded
# to --ff-only-only — which is ancestry-only, not
# test-status. A green-but-flaky branch would
# ff-promote red commits to the target. The fix:
# require the caller to add `administration: read`
# to its permissions block, or explicitly accept the
# no-gates posture by removing branch protection on
# the source branch.
#
# `gh api` exit code is 0 only on 2xx; non-zero on anything
# else. We use --include to capture HTTP status to discriminate.
if PROTECTION_RESP=$(gh api -i "repos/${REPO}/branches/${FROM_BRANCH}/protection/required_status_checks" 2>&1); then
HTTP_STATUS=200
else
HTTP_STATUS=$(echo "$PROTECTION_RESP" | grep -oE '^HTTP/[12](\.[01])? [0-9]{3}' | awk '{print $2}' | head -1)
HTTP_STATUS=${HTTP_STATUS:-unknown}
fi
case "$HTTP_STATUS" in
200)
# Strip headers from gh -i output to get just the body.
GATES_JSON=$(echo "$PROTECTION_RESP" | awk 'p{print} /^[[:space:]]*$/ && !p {p=1}')
;;
404)
echo "::notice::No branch protection on '${FROM_BRANCH}' — relying on --ff-only safety."
echo "ok=true" >> "$GITHUB_OUTPUT"
exit 0
;;
403|401)
echo "::error::Cannot read branch protection on '${FROM_BRANCH}' (HTTP ${HTTP_STATUS})."
echo "::error::Caller's GITHUB_TOKEN lacks 'administration: read' permission."
echo "::error::Refusing to fast-forward without explicit gate enforcement —"
echo "::error::a silent fallback to --ff-only here would let green-but-flaky"
echo "::error::branches promote red commits."
echo "::error::"
echo "::error::Fix: add to the caller's workflow's permissions block:"
echo "::error:: permissions:"
echo "::error:: contents: write"
echo "::error:: statuses: read"
echo "::error:: administration: read"
echo "::error::"
echo "::error::Or, if you intentionally want no-gate enforcement, remove"
echo "::error::branch protection on '${FROM_BRANCH}' so the API returns 404."
exit 1
;;
*)
echo "::error::Unexpected HTTP status '${HTTP_STATUS}' from branch-protection API."
echo "::error::Response (first 5 lines):"
echo "$PROTECTION_RESP" | head -5 | sed 's/^/::error:: /'
exit 1
;;
esac
GATES=$(echo "${GATES_JSON}" | jq -r '.contexts[]?' 2>/dev/null || true)
if [ -z "$GATES" ]; then
echo "::notice::Branch protection on '${FROM_BRANCH}' has zero required-status-checks contexts — relying on --ff-only safety."
echo "ok=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Required gates on '${FROM_BRANCH}':"
echo "${GATES}" | sed 's/^/ - /'
ALL_GREEN=true
while IFS= read -r gate; do
[ -z "$gate" ] && continue
conclusion=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs" \
--jq "[.check_runs[] | select(.name == \"${gate}\")] | sort_by(.completed_at) | last.conclusion" \
2>/dev/null || echo "")
if [ -z "$conclusion" ] || [ "$conclusion" = "null" ]; then
conclusion=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/status" \
--jq "[.statuses[] | select(.context == \"${gate}\")] | sort_by(.updated_at) | last.state" \
2>/dev/null || echo "")
fi
if [ "$conclusion" != "success" ] && [ "$conclusion" != "SUCCESS" ]; then
echo "::warning::Gate '${gate}' is '${conclusion:-missing}' on ${HEAD_SHA} — skipping promote."
ALL_GREEN=false
else
echo " ✓ ${gate}: success"
fi
done <<< "$GATES"
echo "ok=${ALL_GREEN}" >> "$GITHUB_OUTPUT"
- name: Fast-forward target branch to source HEAD
if: steps.gates.outputs.ok == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FROM_BRANCH: ${{ inputs.from-branch }}
TO_BRANCH: ${{ inputs.to-branch }}
shell: bash
run: |
set -euo pipefail
git config user.email "actions@github.com"
git config user.name "github-actions[bot]"
# Source branch is what's checked out (workflow fires on push to
# source). Can't fetch into it. Fetch target into a local target.
git fetch origin "${TO_BRANCH}"
git checkout -B "${TO_BRANCH}" "origin/${TO_BRANCH}"
# Check if target is already at or ahead of source.
if git merge-base --is-ancestor "origin/${FROM_BRANCH}" "${TO_BRANCH}" 2>/dev/null; then
echo "${TO_BRANCH} already contains ${FROM_BRANCH}; nothing to promote."
exit 0
fi
# --ff-only refuses if target has independent commits not on
# source (divergence — hotfix direct to target). Human resolves.
if ! git merge --ff-only "origin/${FROM_BRANCH}" 2>&1; then
echo "::warning::${TO_BRANCH} has diverged from ${FROM_BRANCH} — refusing fast-forward. Resolve manually (likely a direct-to-${TO_BRANCH} commit exists that ${FROM_BRANCH} doesn't have)."
exit 0
fi
git push origin "${TO_BRANCH}"
echo "::notice::Promoted: ${TO_BRANCH} is now at $(git rev-parse --short HEAD)"
@@ -0,0 +1,278 @@
# Gitea Actions port of .github/workflows/auto-promote-staging-pr.yml
# Ported 2026-05-10 per RFC #229 P1-1.
# Caller migration: update `uses: molecule-ai/molecule-ci/.github/workflows/...@ref`
# → `uses: molecule-ai/molecule-ci/.gitea/workflows/...@ref` in your consuming repo.
# Both .github/ and .gitea/ versions exist transitionally for safe consumer migration.
#
# CROSS-REPO INVOCATION CAVEAT (Gitea 1.22.6):
# Per memory feedback_gitea_cross_repo_uses_blocked + audit-force-merge composite
# rationale: cross-repo `uses: org/repo/...@ref` (workflow_call form) does NOT
# resolve on Gitea 1.22.6 because [actions].DEFAULT_ACTIONS_URL=github routes the
# fetch to github.com (where `molecule-ai` is suspended). This file is a port,
# not a switch-on. Until the operator-host server-side flip lands (task #109
# actions/* mirror) consumers calling this via `uses:` will continue to no-op
# /404 even after switching to the .gitea/ path. Land this PR first; flip the
# server config + consumers in a follow-up batch.
name: Auto-promote staging → main (PR-based, reusable)
# Reusable PR-based auto-promote for repos whose `main` branch has
# protection rules that require status checks "set by the expected
# GitHub apps" — direct `git push` from a workflow can't satisfy
# that, only PR merges through the merge queue can.
#
# Distinct from the simpler ff-only auto-promote in this same repo
# (auto-promote-staging.yml): that one does `git merge --ff-only` +
# direct push and only works on repos WITHOUT required-status-checks.
# This reusable workflow is for the protected-branch case.
#
# Call from each repo's .github/workflows/ via a thin wrapper:
#
# name: Auto-promote staging → main
# on:
# workflow_run:
# workflows: [CI, E2E Staging Canvas, ...]
# types: [completed]
# workflow_dispatch:
# inputs:
# force:
# description: "Force promote (manual override)"
# required: false
# default: "false"
# permissions:
# contents: write
# pull-requests: write
# jobs:
# promote:
# uses: molecule-ai/molecule-ci/.github/workflows/auto-promote-staging-pr.yml@v1
# with:
# gates: "ci.yml,e2e-staging-canvas.yml,e2e-api.yml,codeql.yml"
# force: ${{ github.event.inputs.force == 'true' }}
# secrets: inherit
#
# IMPORTANT: the caller MUST keep the `on.workflow_run.workflows`
# display-name list in sync with the `gates` input (which uses
# workflow filenames). The reusable can't validate this — display
# names and filenames are decoupled in GitHub Actions.
#
# Required repo settings (one-time, in the CALLER repo):
#
# Settings → Actions → General → Workflow permissions
# → ✅ Allow GitHub Actions to create and approve pull requests
#
# Without it, every workflow run fails with:
#
# pull request create failed: GraphQL: GitHub Actions is not
# permitted to create or approve pull requests (createPullRequest)
#
# Toggle: caller repo variable AUTO_PROMOTE_ENABLED=true. Override
# via the `enabled-var` input if a different name is needed.
# When the variable is unset, the workflow logs what it would have
# done but doesn't open the PR — useful for dry-running the gate
# logic without surfacing a noisy PR while staging CI is still flaky.
on:
workflow_call:
inputs:
gates:
description: >-
Comma-separated list of workflow FILENAMES (not display
names) that must be conclusion=success on the staging head
SHA before promote fires. Example:
"ci.yml,e2e-staging-canvas.yml,codeql.yml". File paths are
used (not display names) because gh run list with display
names is ambiguous when two workflows share a name (observed
2026-04-28 with codeql.yml + GitHub UI's Code-quality default
setup both surfacing as "CodeQL").
required: true
type: string
target-branch:
description: "Target branch to promote TO (default: main)"
required: false
type: string
default: main
source-branch:
description: "Source branch to promote FROM (default: staging)"
required: false
type: string
default: staging
enabled-var:
description: >-
Repo variable name that gates this workflow. Set this
variable to "true" in the caller repo's Settings →
Variables → Actions to enable. Defaults to
AUTO_PROMOTE_ENABLED.
required: false
type: string
default: AUTO_PROMOTE_ENABLED
merge-method:
description: >-
Merge method for `gh pr merge --auto`. One of merge|squash|
rebase. Defaults to "merge" (matches user preference for
merge commits over squash).
required: false
type: string
default: merge
force:
description: >-
Skip the AUTO_PROMOTE_ENABLED variable check. Pass true
when the caller's workflow_dispatch input is force=true.
Default false.
required: false
type: boolean
default: false
jobs:
check-all-gates-green:
# Only consider promotions for the source branch's push events.
# PR runs into the source branch don't promote. workflow_dispatch
# passes through unconditionally.
if: >
(github.event_name == 'workflow_run' &&
github.event.workflow_run.head_branch == inputs.source-branch &&
github.event.workflow_run.event == 'push')
|| github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
outputs:
all_green: ${{ steps.gates.outputs.all_green }}
head_sha: ${{ steps.gates.outputs.head_sha }}
steps:
- name: Check all required gates on this SHA
id: gates
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
REPO: ${{ github.repository }}
GATES_CSV: ${{ inputs.gates }}
SOURCE_BRANCH: ${{ inputs.source-branch }}
run: |
set -euo pipefail
# Split the comma-separated gates input. Trim whitespace per
# entry so callers can format readably (e.g. "ci.yml, e2e.yml").
IFS=',' read -ra GATES <<< "$GATES_CSV"
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
echo "Checking gates on SHA ${HEAD_SHA}"
ALL_GREEN=true
for gate_raw in "${GATES[@]}"; do
gate="${gate_raw## }"
gate="${gate%% }"
if [ -z "$gate" ]; then
continue
fi
# Query the most recent run of this workflow on this SHA.
# event=push to avoid picking up PR runs. branch filter
# guards against someone dispatching the gate on a non-
# source branch at the same SHA.
RESULT=$(gh run list \
--repo "$REPO" \
--workflow "$gate" \
--branch "$SOURCE_BRANCH" \
--event push \
--commit "$HEAD_SHA" \
--limit 1 \
--json status,conclusion \
--jq '.[0] | "\(.status)/\(.conclusion // "none")"' \
2>/dev/null || echo "missing/none")
echo " $gate → $RESULT"
# Only completed/success counts. Anything else aborts.
if [ "$RESULT" != "completed/success" ]; then
ALL_GREEN=false
fi
done
echo "all_green=${ALL_GREEN}" >> "$GITHUB_OUTPUT"
if [ "$ALL_GREEN" != "true" ]; then
echo "::notice::auto-promote: not all gates are green on ${HEAD_SHA} — staying on current ${{ inputs.target-branch }}"
fi
promote:
needs: check-all-gates-green
if: needs.check-all-gates-green.outputs.all_green == 'true'
runs-on: ubuntu-latest
steps:
- name: Check rollout gate
env:
ENABLED_VAR_NAME: ${{ inputs.enabled-var }}
ENABLED_VAR_VALUE: ${{ vars[inputs.enabled-var] }}
FORCE: ${{ inputs.force }}
run: |
set -eu
# Caller repo controls rollout via the named variable.
# Default name is AUTO_PROMOTE_ENABLED; callers can override.
if [ "${ENABLED_VAR_VALUE:-}" != "true" ] && [ "${FORCE:-false}" != "true" ]; then
{
echo "## ⏸ Auto-promote disabled"
echo
echo "Repo variable \`${ENABLED_VAR_NAME}\` is not set to \`true\`."
echo "All gates are green on ${{ inputs.source-branch }}; would have opened a promote PR to \`${{ inputs.target-branch }}\`."
echo
echo "To enable: Settings → Secrets and variables → Actions → Variables → \`${ENABLED_VAR_NAME}=true\`."
echo "To test once manually: workflow_dispatch with \`force=true\`."
} >> "$GITHUB_STEP_SUMMARY"
echo "::notice::auto-promote disabled — dry run only"
exit 0
fi
- name: Open (or reuse) ${{ inputs.source-branch }} → ${{ inputs.target-branch }} promote PR + enable auto-merge
if: ${{ vars[inputs.enabled-var] == 'true' || inputs.force == true }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TARGET_SHA: ${{ needs.check-all-gates-green.outputs.head_sha }}
SOURCE_BRANCH: ${{ inputs.source-branch }}
TARGET_BRANCH: ${{ inputs.target-branch }}
MERGE_METHOD: ${{ inputs.merge-method }}
GATES_CSV: ${{ inputs.gates }}
run: |
set -euo pipefail
# Look for an existing open promote PR (idempotent on re-run).
# The PR's head IS the source branch — the whole point is
# "advance target to source's tip", so we don't need a per-SHA
# branch like auto-sync-main-to-staging.yml uses.
PR_NUM=$(gh pr list --repo "$REPO" \
--base "$TARGET_BRANCH" --head "$SOURCE_BRANCH" --state open \
--json number --jq '.[0].number // ""')
if [ -z "$PR_NUM" ]; then
TITLE="${SOURCE_BRANCH} → ${TARGET_BRANCH}: auto-promote ${TARGET_SHA:0:7}"
BODY_FILE=$(mktemp)
cat > "$BODY_FILE" <<EOFBODY
Automated promotion of \`${SOURCE_BRANCH}\` (\`${TARGET_SHA:0:8}\`) to \`${TARGET_BRANCH}\`. Required gates green at this SHA: ${GATES_CSV}.
This PR is auto-generated by a thin caller of \`molecule-ai/molecule-ci/.github/workflows/auto-promote-staging-pr.yml\` whenever every required gate completes green on the same source-branch SHA. It exists because protected branches require status checks "set by the expected GitHub apps" — direct \`git push\` from a workflow can't satisfy that, only PR merges through the queue can.
Merge queue lands this; no human action needed unless gates fail.
EOFBODY
PR_URL=$(gh pr create --repo "$REPO" \
--base "$TARGET_BRANCH" --head "$SOURCE_BRANCH" \
--title "$TITLE" \
--body-file "$BODY_FILE")
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$' | tail -1)
rm -f "$BODY_FILE"
echo "::notice::Opened PR #${PR_NUM}"
else
echo "::notice::Re-using existing promote PR #${PR_NUM}"
fi
# Enable auto-merge — the merge queue picks it up once
# required gates are green on the merge_group ref.
if ! gh pr merge "$PR_NUM" --repo "$REPO" --auto --"$MERGE_METHOD" 2>&1; then
echo "::warning::Failed to enable auto-merge on PR #${PR_NUM} — operator may need to merge manually."
fi
{
echo "## ✅ Auto-promote PR opened"
echo
echo "- Source: \`${SOURCE_BRANCH}\` at \`${TARGET_SHA:0:8}\`"
echo "- Target: \`${TARGET_BRANCH}\`"
echo "- PR: #${PR_NUM}"
echo
echo "Merge queue lands the PR once required gates are green; no human action needed unless gates fail."
} >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,69 @@
# Gitea Actions port of .github/workflows/disable-auto-merge-on-push.yml
# Ported 2026-05-10 per RFC #229 P1-1.
# Caller migration: update `uses: molecule-ai/molecule-ci/.github/workflows/...@ref`
# → `uses: molecule-ai/molecule-ci/.gitea/workflows/...@ref` in your consuming repo.
# Both .github/ and .gitea/ versions exist transitionally for safe consumer migration.
#
# CROSS-REPO INVOCATION CAVEAT (Gitea 1.22.6):
# Per memory feedback_gitea_cross_repo_uses_blocked + audit-force-merge composite
# rationale: cross-repo `uses: org/repo/...@ref` (workflow_call form) does NOT
# resolve on Gitea 1.22.6 because [actions].DEFAULT_ACTIONS_URL=github routes the
# fetch to github.com (where `molecule-ai` is suspended). This file is a port,
# not a switch-on. Until the operator-host server-side flip lands (task #109
# actions/* mirror) consumers calling this via `uses:` will continue to no-op
# /404 even after switching to the .gitea/ path. Land this PR first; flip the
# server config + consumers in a follow-up batch.
name: Disable auto-merge on push
# Reusable guard against the "I enabled auto-merge then pushed more
# commits" race. Background: on 2026-04-27, PR #2174 in molecule-core
# auto-merged with only the first commit because the second commit
# was pushed AFTER the merge queue had already locked the PR's SHA.
# The second commit ended up orphaned on a merged-and-deleted branch.
#
# Mechanism: on every `pull_request: synchronize` event (= new commit
# pushed to an open PR), check if auto-merge is enabled. If yes,
# disable it and post a comment. This forces the operator to
# re-engage `gh pr merge --auto` after the new push, with the
# re-engagement acting as the verification step.
#
# Call from each repo's .github/workflows/ via a thin wrapper:
#
# name: pr-guards
# on:
# pull_request:
# types: [synchronize]
# permissions:
# pull-requests: write
# jobs:
# disable-auto-merge-on-push:
# uses: molecule-ai/molecule-ci/.github/workflows/disable-auto-merge-on-push.yml@v1
#
# False-positive behavior: if a CI bot pushes (e.g. dependency-update
# rebase, secret rotation), this also disables auto-merge for that
# PR. That's acceptable — the operator who originally enabled
# auto-merge gets notified and re-engages, which is exactly the
# verify-after-machine-edits behavior we want.
on:
workflow_call:
jobs:
guard:
name: Disable auto-merge on push
runs-on: ubuntu-latest
if: github.event.pull_request.auto_merge != null
permissions:
pull-requests: write
steps:
- name: Disable auto-merge
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
NEW_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
gh pr merge "$PR" --disable-auto -R "$REPO" || true
gh pr comment "$PR" -R "$REPO" --body "🔒 Auto-merge disabled — new commit (\`${NEW_SHA:0:7}\`) pushed after auto-merge was enabled. The merge queue locks SHAs at entry, so subsequent pushes can race. Verify the new commit and re-enable with \`gh pr merge --auto\`."
+413
View File
@@ -0,0 +1,413 @@
# Gitea Actions port of .github/workflows/publish-template-image.yml
# Ported 2026-05-10 per RFC #229 P1-1.
# Caller migration: update `uses: molecule-ai/molecule-ci/.github/workflows/...@ref`
# → `uses: molecule-ai/molecule-ci/.gitea/workflows/...@ref` in your consuming repo.
# Both .github/ and .gitea/ versions exist transitionally for safe consumer migration.
#
# CROSS-REPO INVOCATION CAVEAT (Gitea 1.22.6):
# Per memory feedback_gitea_cross_repo_uses_blocked + audit-force-merge composite
# rationale: cross-repo `uses: org/repo/...@ref` (workflow_call form) does NOT
# resolve on Gitea 1.22.6 because [actions].DEFAULT_ACTIONS_URL=github routes the
# fetch to github.com (where `molecule-ai` is suspended). This file is a port,
# not a switch-on. Until the operator-host server-side flip lands (task #109
# actions/* mirror) consumers calling this via `uses:` will continue to no-op
# /404 even after switching to the .gitea/ path. Land this PR first; flip the
# server config + consumers in a follow-up batch.
name: Publish Workspace Template Image
# Reusable workflow for every molecule-ai/molecule-ai-workspace-template-*
# repo. Builds the template's Dockerfile on main and pushes to GHCR as
# `ghcr.io/molecule-ai/workspace-template-<runtime>:latest` (plus a
# per-commit `sha-<7>` tag). Auto-derives <runtime> from the caller repo
# name so the per-repo wrapper stays one line.
#
# Call from each template repo like:
#
# name: publish-image
# on:
# push: { branches: [main] }
# workflow_dispatch:
# permissions:
# contents: read
# packages: write
# jobs:
# publish:
# uses: molecule-ai/molecule-ci/.github/workflows/publish-template-image.yml@v1
# secrets: inherit
#
# Runner choice (2026-04-22): ubuntu-latest
# - All caller repos are PUBLIC → GHA-hosted minutes are free.
# - Targets are linux/amd64 natively; Ubuntu runners skip QEMU that
# our arm64 Mac mini had to emulate through, so builds go ~2-3x
# faster on top of having no queue wait when the Mac mini is busy.
# - No macOS Keychain gymnastics — standard docker/login-action works.
# The self-hosted Mac mini remains in service for private repo
# workflows (see memory: feedback_selfhosted_runner).
on:
workflow_call:
inputs:
runtime_name:
description: >-
Optional explicit runtime name. When unset, derived from
the caller repo name (strips `molecule-ai-workspace-template-`
prefix). Override only if the image should diverge.
required: false
type: string
default: ""
runtime_version:
description: >-
molecule-ai-workspace-runtime version to install. Forwarded
as RUNTIME_VERSION docker build-arg. When unset, the
Dockerfile's requirements.txt pin is used. Cascade-triggered
builds forward client_payload.runtime_version here so each
rebuild has a unique build-arg → unique cache key →
guaranteed fresh `pip install`. Solves the
"cascade rebuilt but image still has old runtime" cache
trap that bit us repeatedly on 2026-04-27.
required: false
type: string
default: ""
outputs:
image:
description: "Full image reference that was pushed (with :latest tag)"
value: ${{ jobs.publish.outputs.image }}
sha:
description: "Short SHA tag pushed alongside :latest"
value: ${{ jobs.publish.outputs.sha }}
jobs:
publish:
name: Build & push template image
runs-on: ubuntu-latest
outputs:
image: ${{ steps.tags.outputs.image }}
sha: ${{ steps.tags.outputs.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive runtime name + image reference
id: tags
shell: bash
env:
EXPLICIT_RUNTIME: ${{ inputs.runtime_name }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -eu
if [ -n "${EXPLICIT_RUNTIME}" ]; then
RUNTIME="${EXPLICIT_RUNTIME}"
else
# Repo naming convention:
# molecule-ai-workspace-template-<runtime>
# Strip the prefix to get <runtime>.
case "${REPO_NAME}" in
molecule-ai-workspace-template-*)
RUNTIME="${REPO_NAME#molecule-ai-workspace-template-}"
;;
*)
echo "::error::Repo name '${REPO_NAME}' does not match 'molecule-ai-workspace-template-<runtime>' — pass runtime_name explicitly." >&2
exit 1
;;
esac
fi
IMAGE="ghcr.io/molecule-ai/workspace-template-${RUNTIME}"
SHA="${GITHUB_SHA::7}"
echo "runtime=${RUNTIME}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
echo "::notice::Publishing runtime='${RUNTIME}' → ${IMAGE}:latest + :sha-${SHA}"
- name: Lint — no bare imports of runtime modules
# Templates that bare-import a workspace/ runtime module
# (e.g. `from plugins import load_plugins` instead of
# `from molecule_runtime.plugins import load_plugins`) work in
# the monorepo's bundled-runtime layout but explode at startup
# with `ModuleNotFoundError` once the runtime is installed as a
# package. This bit claude-code (5 imports), langgraph,
# deepagents, and gemini-cli on 2026-04-27 — each one a
# separate workspace-stuck-in-provisioning incident.
#
# Source of truth: molecule_runtime/_runtime_modules.json
# inside the published wheel (emitted by
# scripts/build_runtime_package.py). Pulling the manifest
# from PyPI's latest wheel ensures the lint never drifts from
# the rewriter's actual closed list. If the manifest can't be
# fetched (older wheel, PyPI down, etc.), falls back to the
# inline list — known to be correct as of 2026-04-27 — so
# the lint never silently passes on a fetch failure.
#
# Fail-fast: this runs before docker login + buildx setup so
# a bad PR returns red in seconds, not minutes.
shell: bash
run: |
set -eu
# Fallback list — used only when the manifest fetch fails.
# Mirrors scripts/build_runtime_package.py:TOP_LEVEL_MODULES
# at the time this comment was written.
FALLBACK_MODULES='plugins|adapter_base|config|main|preflight|prompt|coordinator|consolidation|events|heartbeat|transcript_auth|runtime_wedge|watcher|skill_loader|policies|adapters|builtin_tools|executor_helpers|a2a_executor|a2a_client|a2a_tools|a2a_cli|a2a_mcp_server|agent|agents_md|initial_prompt|molecule_ai_status|platform_auth|shared_runtime'
RUNTIME_MODULES=""
mkdir -p /tmp/runtime-wheel
if pip download --quiet molecule-ai-workspace-runtime --no-deps -d /tmp/runtime-wheel 2>/dev/null; then
WHEEL=$(ls /tmp/runtime-wheel/*.whl 2>/dev/null | head -1)
if [ -n "$WHEEL" ]; then
# Pull both top_level + subpackage names; both can be bare-imported.
RUNTIME_MODULES=$(unzip -p "$WHEEL" molecule_runtime/_runtime_modules.json 2>/dev/null \
| python3 -c "import sys,json; m=json.load(sys.stdin); print('|'.join(sorted(set(m['top_level_modules']) | set(m['subpackages']))))" 2>/dev/null || echo "")
fi
fi
if [ -n "$RUNTIME_MODULES" ]; then
echo "::notice::lint module list pulled from molecule-ai-workspace-runtime wheel manifest"
else
RUNTIME_MODULES="$FALLBACK_MODULES"
echo "::warning::could not read _runtime_modules.json from PyPI wheel — using inline fallback list"
fi
# Match `from <module> import` at start of line OR after any whitespace
# (function-scope imports inside if/try blocks count too).
if HITS=$(grep -nE "^\s*from (${RUNTIME_MODULES}) import" *.py 2>/dev/null); then
echo "::error::Bare imports of runtime modules found — must use \`from molecule_runtime.<module> import\`"
echo "$HITS" | sed 's/^/ /'
echo "::error::Fix: prefix each match with 'molecule_runtime.' (e.g. 'from plugins' → 'from molecule_runtime.plugins')."
exit 1
fi
echo "::notice::✓ no bare imports of runtime modules in template *.py files"
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build template image (load for smoke test, do not push yet)
# Build into the runner's local docker first so the smoke test can
# actually boot the image. We push :latest + :sha-* only AFTER the
# smoke test passes — this is the gate that prevents broken images
# from poisoning :latest. Background: 2026-04-27 outage where the
# template's adapter.py imported a symbol (RuntimeCapabilities)
# that the published runtime didn't yet export. The old smoke
# test only inspected the entrypoint string, so the broken image
# shipped to GHCR and every workspace provision hung.
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
load: true
push: false
tags: ${{ steps.tags.outputs.image }}:sha-${{ steps.tags.outputs.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# RUNTIME_VERSION is empty by default. When the cascade fires
# (or workflow_dispatch is invoked with a version), it's the
# exact runtime version about to be installed. Forwarded as a
# build-arg so Dockerfiles that declare `ARG RUNTIME_VERSION`
# get cache-key invalidation per-version. Templates that
# don't declare the ARG silently ignore it (no breakage).
build-args: |
RUNTIME_VERSION=${{ inputs.runtime_version }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.description=Molecule AI workspace template — ${{ steps.tags.outputs.runtime }} runtime
- name: Smoke test — boot image and import every /app/*.py
# The real boot test. Imports every Python module at /app/ inside
# the image, which exercises:
# - adapter.py exists, no syntax errors, all module-level
# imports resolve against the pip-installed runtime version
# (catches version skew — symbol added to runtime but PyPI
# not yet republished, etc.)
# - executor.py / cli_executor.py / claude_sdk_executor.py /
# etc. — sibling modules adapter.py imports lazily inside
# create_executor(). Plain `import adapter` doesn't catch
# bugs there because they're behind `def create_executor`.
# This bit hermes (a2a-sdk migration) and langgraph
# (LangGraphA2AExecutor bare import) on 2026-04-27.
# - cross-cutting: any bare `from <runtime_module>` (the lint
# above catches these statically; this catches them at
# resolution time too, plus any imports of third-party
# packages that the lint can't reason about).
# We bypass the gosu/agent entrypoint with --entrypoint sh
# because import smoke doesn't need workspace permissions.
shell: bash
env:
IMAGE: ${{ steps.tags.outputs.image }}:sha-${{ steps.tags.outputs.sha }}
run: |
set -eu
docker run --rm --entrypoint sh "${IMAGE}" -c '
set -e
cd /app
for f in *.py; do
[ "$f" = "__init__.py" ] && continue
mod="${f%.py}"
python3 -c "import $mod" || { echo "::error::failed to import $mod"; exit 1; }
echo " ✓ $mod"
done
'
echo "::notice::✓ ${IMAGE} all /app/*.py modules import cleanly against installed runtime"
- name: Boot smoke — execute() against stub deps (#2275, task #131)
# The static import smoke above only IMPORTs /app/*.py — lazy
# imports buried inside `async def execute(...)` bodies (e.g.
# `from a2a.types import FilePart`) NEVER evaluate at static-
# import time. The 2026-04-2x v0→v1 a2a-sdk migration shipped 5
# such regressions in templates that all looked fine at module-
# load smoke (claude-code, langgraph, deepagents, gemini-cli,
# hermes — every one a separate provisioning incident).
#
# This step boots the image with MOLECULE_SMOKE_MODE=1, which
# routes molecule-runtime through smoke_mode.run_executor_smoke()
# — invokes executor.execute(stub_ctx, stub_queue) once with a
# short timeout. Healthy import tree → execution proceeds far
# enough to hit a network boundary and times out (exit 0).
# Broken lazy import → ImportError/ModuleNotFoundError from
# inside the executor body (exit 1).
#
# Universal turn-smoke (task #131): run_executor_smoke also
# consults runtime_wedge.is_wedged() at the end of every result
# path and upgrades a provisional PASS to FAIL when an adapter
# marked the runtime wedged. Catches PR-25-class regressions
# (claude-agent-sdk init wedge from a malformed CLI argv) where
# the SDK takes 60s to time out on `initialize()` — the outer
# wait_for must outlast that handshake so the adapter's wedge
# catch arm runs before the smoke gives up. That's why the
# smoke timeout is 90s (NOT the original 10s) and the outer
# `timeout` wrapper is 120s (NOT 60s). Lowering either back
# makes this gate blind to init-wedge bugs again — confirm with
# an injected wedge in test_smoke_mode.py before changing.
#
# Requires runtime >= 0.1.60 (the version that introduced
# smoke_mode). Older runtimes silently no-op and would hang on
# uvicorn, so we detect the module first and skip if absent —
# this lets templates pinned to older runtimes continue to
# publish without this gate flipping red, while every fresh
# cascade-triggered build (which forwards the just-published
# version as RUNTIME_VERSION) gets the gate automatically.
#
# Wrapped in `timeout` as a belt-and-suspenders safety net in
# case smoke_mode itself wedges — runner shouldn't hang
# indefinitely on a single template.
shell: bash
env:
IMAGE: ${{ steps.tags.outputs.image }}:sha-${{ steps.tags.outputs.sha }}
run: |
set -eu
HAS_SMOKE_MODE=$(docker run --rm --entrypoint sh "${IMAGE}" -c \
'python3 -c "import molecule_runtime.smoke_mode" >/dev/null 2>&1 && echo yes || echo no')
if [ "${HAS_SMOKE_MODE}" = "no" ]; then
echo "::warning::installed runtime predates molecule-core#2275 (no molecule_runtime.smoke_mode); skipping boot smoke. Bump requirements.txt to molecule-ai-workspace-runtime>=0.1.60 to enable."
exit 0
fi
if [ ! -f config.yaml ]; then
echo "::error::config.yaml not found at repo root — boot smoke needs it to populate /configs. Templates without a config.yaml at root cannot be boot-smoked; either add one or skip this gate by setting an old runtime pin."
exit 1
fi
# Mount the repo's own config.yaml at /configs so the runtime
# can reach create_executor() — that's where the lazy imports
# we want to test actually live. The image's entrypoint drops
# priv from root to agent (uid 1000) before exec'ing
# molecule-runtime, so /configs needs to be readable AND
# traversable from uid 1000.
#
# Use `a+rX` (capital X — only adds x where it's already
# executable, i.e. directories): mktemp -d creates the dir
# with mode 700, so a bare `go+r` would leave the dir
# un-traversable for agent and config.py would
# PermissionError on `Path('/configs/config.yaml').exists()`.
# Mount RW (not :ro) so the entrypoint's `chown -R agent
# /configs` succeeds — its silent chown failure on a :ro
# mount was the original symptom.
SMOKE_CONFIG_DIR=$(mktemp -d)
cp config.yaml "${SMOKE_CONFIG_DIR}/"
chmod -R a+rX "${SMOKE_CONFIG_DIR}"
# Stub credentials — adapters validate shape at create_executor
# time but the smoke times out before any real call goes out.
# Set the common ones so any adapter that early-validates a
# specific key sees a non-empty value.
# PYTHONPATH=/app mirrors what the platform's provisioner
# injects at workspace startup (workspace-server/internal/
# provisioner/provisioner.go:563). Without it,
# `importlib.import_module('adapter')` in the runtime's
# preflight check fails with ModuleNotFoundError because
# molecule-runtime is a console_scripts entry point —
# sys.path[0] is /usr/local/bin, NOT /app. The existing
# static import smoke step above doesn't hit this because
# `python3 -c "import $mod"` adds cwd to sys.path; only the
# entry-point invocation needs PYTHONPATH.
set +e
# MOLECULE_SMOKE_TIMEOUT_SECS=90 is calibrated to outlast
# claude-agent-sdk's 60s initialize() handshake (see step
# comment above + workspace/smoke_mode.py top docstring) so
# adapter wedge catch arms run before run_executor_smoke
# gives up. Outer `timeout 120` is the runner-level safety
# net — slightly longer than the inner timeout so a hung
# smoke_mode itself surfaces as exit 124 and gets a clear
# error message instead of just `exit 1`.
timeout 120 docker run --rm \
-v "${SMOKE_CONFIG_DIR}:/configs" \
-e WORKSPACE_ID=fake-smoke \
-e PYTHONPATH=/app \
-e MOLECULE_SMOKE_MODE=1 \
-e MOLECULE_SMOKE_TIMEOUT_SECS=90 \
-e CLAUDE_CODE_OAUTH_TOKEN=sk-fake-smoke-token \
-e ANTHROPIC_API_KEY=sk-fake-smoke-key \
-e GEMINI_API_KEY=fake-smoke-key \
-e OPENAI_API_KEY=sk-fake-smoke-key \
"${IMAGE}"
rc=$?
set -e
# Cleanup is best-effort: the entrypoint chowns /configs to
# uid 1000 (agent) inside the container, which propagates to
# the host bind-mount, leaving the runner user unable to
# remove the files. Fall back to `sudo rm` and ignore any
# remaining failure — the runner is ephemeral, /tmp is
# cleaned automatically post-job.
rm -rf "${SMOKE_CONFIG_DIR}" 2>/dev/null \
|| sudo rm -rf "${SMOKE_CONFIG_DIR}" 2>/dev/null \
|| true
if [ "${rc}" -eq 124 ]; then
echo "::error::boot smoke wedged past 120s — smoke_mode itself failed to terminate (look for blocking calls before MOLECULE_SMOKE_TIMEOUT_SECS fires)"
exit 1
fi
if [ "${rc}" -ne 0 ]; then
echo "::error::boot smoke failed (exit ${rc}) — executor.execute() raised an import error OR an adapter marked runtime_wedge.is_wedged() (PR-25-class init wedge). Check the container log above for the offending lazy import or wedge reason."
exit "${rc}"
fi
echo "::notice::✓ ${IMAGE} executor.execute() smoke passed (imports healthy, no runtime wedge)"
- name: Push image to GHCR (post-smoke)
# Now that the smoke test passed, push both tags. build-push-action
# reuses the cached build from the load step above, so this is fast
# — it's effectively a layer push, not a rebuild. Same build-args
# passed for cache key consistency.
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ steps.tags.outputs.image }}:latest
${{ steps.tags.outputs.image }}:sha-${{ steps.tags.outputs.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
RUNTIME_VERSION=${{ inputs.runtime_version }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.description=Molecule AI workspace template — ${{ steps.tags.outputs.runtime }} runtime
@@ -0,0 +1,93 @@
# Gitea Actions port of .github/workflows/validate-org-template.yml
# Ported 2026-05-10 per RFC #229 P1-1.
# Caller migration: update `uses: molecule-ai/molecule-ci/.github/workflows/...@ref`
# → `uses: molecule-ai/molecule-ci/.gitea/workflows/...@ref` in your consuming repo.
# Both .github/ and .gitea/ versions exist transitionally for safe consumer migration.
#
# CROSS-REPO INVOCATION CAVEAT (Gitea 1.22.6):
# Per memory feedback_gitea_cross_repo_uses_blocked + audit-force-merge composite
# rationale: cross-repo `uses: org/repo/...@ref` (workflow_call form) does NOT
# resolve on Gitea 1.22.6 because [actions].DEFAULT_ACTIONS_URL=github routes the
# fetch to github.com (where `molecule-ai` is suspended). This file is a port,
# not a switch-on. Until the operator-host server-side flip lands (task #109
# actions/* mirror) consumers calling this via `uses:` will continue to no-op
# /404 even after switching to the .gitea/ path. Land this PR first; flip the
# server config + consumers in a follow-up batch.
name: Validate Org Template
on:
workflow_call:
jobs:
validate:
name: Org template validation
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# Canonical validator script lives in molecule-ci, fetched fresh on
# every run. The previous setup expected `.molecule-ci/scripts/` to
# be vendored INTO each org-template repo, which drifted across the
# 5 org-template repos as the validator evolved. Single source of
# truth eliminates that drift class entirely. Mirrors the same
# pattern already used by validate-workspace-template.yml.
# Direct git-clone — see validate-plugin.yml for the rationale.
# Anonymous fetch of public molecule-ci, no actions/checkout idiosyncrasies.
- name: Fetch molecule-ci canonical scripts
run: git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git .molecule-ci-canonical
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
cache-dependency-path: .molecule-ci-canonical/.molecule-ci/scripts/requirements.txt
- run: pip install pyyaml -q
- run: python3 .molecule-ci-canonical/.molecule-ci/scripts/validate-org-template.py
- name: Check for secrets
run: |
python3 - << 'PYEOF'
import os, re, sys
from pathlib import Path
PATTERNS = [
re.compile(r'''["']sk-ant-[a-zA-Z0-9]{50,}["']'''),
re.compile(r'''["']ghp_[a-zA-Z0-9]{36,}["']'''),
re.compile(r'''["']AKIA[A-Z0-9]{16}["']'''),
re.compile(r'''["'][a-zA-Z0-9/+=]{40}["']'''),
re.compile(r'''["']sk_test_[a-zA-Z0-9]{24,}["']'''),
re.compile(r'''["']Bearer\s+[a-zA-Z0-9_.-]{20,}["']'''),
re.compile(r'''ghp_[a-zA-Z0-9]{36,}'''),
re.compile(r'''sk-ant-[a-zA-Z0-9]{50,}'''),
]
SKIP_DIRS = {'.molecule-ci', '.molecule-ci-canonical', '.git', 'node_modules', '__pycache__'}
EXTENSIONS = {'.yaml', '.yml', '.md', '.py', '.sh'}
def is_false_positive(line):
ctx = line.lower()
return '...' in ctx or '<example' in ctx or '</example' in ctx
root = Path(os.environ.get('GITHUB_WORKSPACE', '.'))
warnings = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for filename in filenames:
if Path(filename).suffix not in EXTENSIONS:
continue
filepath = Path(dirpath) / filename
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
for lineno, line in enumerate(f.readlines(), 1):
for pattern in PATTERNS:
for match in pattern.finditer(line):
if not is_false_positive(line):
warnings.append(f" {filepath}:{lineno}: {match.group(0)[:40]}...")
except Exception:
pass
if warnings:
print("::error::Potential secret found in committed files:")
for w in warnings:
print(w)
sys.exit(1)
else:
print("::notice::No secrets detected")
PYEOF
+102
View File
@@ -0,0 +1,102 @@
# Gitea Actions port of .github/workflows/validate-plugin.yml
# Ported 2026-05-10 per RFC #229 P1-1.
# Caller migration: update `uses: molecule-ai/molecule-ci/.github/workflows/...@ref`
# → `uses: molecule-ai/molecule-ci/.gitea/workflows/...@ref` in your consuming repo.
# Both .github/ and .gitea/ versions exist transitionally for safe consumer migration.
#
# CROSS-REPO INVOCATION CAVEAT (Gitea 1.22.6):
# Per memory feedback_gitea_cross_repo_uses_blocked + audit-force-merge composite
# rationale: cross-repo `uses: org/repo/...@ref` (workflow_call form) does NOT
# resolve on Gitea 1.22.6 because [actions].DEFAULT_ACTIONS_URL=github routes the
# fetch to github.com (where `molecule-ai` is suspended). This file is a port,
# not a switch-on. Until the operator-host server-side flip lands (task #109
# actions/* mirror) consumers calling this via `uses:` will continue to no-op
# /404 even after switching to the .gitea/ path. Land this PR first; flip the
# server config + consumers in a follow-up batch.
name: Validate Plugin
on:
workflow_call:
jobs:
validate:
name: Plugin validation
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# Canonical validator script lives in molecule-ci, fetched fresh on
# every run. The previous setup expected `.molecule-ci/scripts/` to
# be vendored INTO each plugin repo, which drifted across the
# 20+ plugin repos as the validator evolved. Single source of
# truth eliminates that drift class entirely. Mirrors the same
# pattern already used by validate-workspace-template.yml.
# Direct git-clone instead of actions/checkout@v4 because:
# (a) actions/checkout@v4 sends Authorization: basic <github.token> by default,
# and Gitea 404s the cross-repo authenticated request (different from
# GitHub which falls back to anon-public-read).
# (b) Passing token: '' triggers actions/checkout's runtime "Input required
# and not supplied: token" error — the input is documented as
# required:false but the action's runtime calls getInput with
# required:true on its auth-helper path.
# Anonymous git clone of public molecule-ci has neither problem.
# See molecule-ci#1 (lowercase fix) + #2 (token:'' attempt) +
# the post-merge CI run on plugin-molecule-careful-bash@663bf72.
- name: Fetch molecule-ci canonical scripts
run: git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git .molecule-ci-canonical
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
cache-dependency-path: .molecule-ci-canonical/.molecule-ci/scripts/requirements.txt
- run: pip install pyyaml -q
- run: python3 .molecule-ci-canonical/.molecule-ci/scripts/validate-plugin.py
- name: Check for secrets
run: |
python3 - << 'PYEOF'
import os, re, sys
from pathlib import Path
PATTERNS = [
re.compile(r'''["']sk-ant-[a-zA-Z0-9]{50,}["']'''),
re.compile(r'''["']ghp_[a-zA-Z0-9]{36,}["']'''),
re.compile(r'''["']AKIA[A-Z0-9]{16}["']'''),
re.compile(r'''["'][a-zA-Z0-9/+=]{40}["']'''),
re.compile(r'''["']sk_test_[a-zA-Z0-9]{24,}["']'''),
re.compile(r'''["']Bearer\s+[a-zA-Z0-9_.-]{20,}["']'''),
re.compile(r'''ghp_[a-zA-Z0-9]{36,}'''),
re.compile(r'''sk-ant-[a-zA-Z0-9]{50,}'''),
]
SKIP_DIRS = {'.molecule-ci', '.molecule-ci-canonical', '.git', 'node_modules', '__pycache__'}
EXTENSIONS = {'.yaml', '.yml', '.md', '.py', '.sh'}
def is_false_positive(line):
ctx = line.lower()
return '...' in ctx or '<example' in ctx or '</example' in ctx
root = Path(os.environ.get('GITHUB_WORKSPACE', '.'))
warnings = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for filename in filenames:
if Path(filename).suffix not in EXTENSIONS:
continue
filepath = Path(dirpath) / filename
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
for lineno, line in enumerate(f.readlines(), 1):
for pattern in PATTERNS:
for match in pattern.finditer(line):
if not is_false_positive(line):
warnings.append(f" {filepath}:{lineno}: {match.group(0)[:40]}...")
except Exception:
pass
if warnings:
print("::error::Potential secret found in committed files:")
for w in warnings:
print(w)
sys.exit(1)
else:
print("::notice::No secrets detected")
PYEOF
@@ -0,0 +1,240 @@
# Gitea Actions port of .github/workflows/validate-workspace-template.yml
# Ported 2026-05-10 per RFC #229 P1-1.
# Caller migration: update `uses: molecule-ai/molecule-ci/.github/workflows/...@ref`
# → `uses: molecule-ai/molecule-ci/.gitea/workflows/...@ref` in your consuming repo.
# Both .github/ and .gitea/ versions exist transitionally for safe consumer migration.
#
# CROSS-REPO INVOCATION CAVEAT (Gitea 1.22.6):
# Per memory feedback_gitea_cross_repo_uses_blocked + audit-force-merge composite
# rationale: cross-repo `uses: org/repo/...@ref` (workflow_call form) does NOT
# resolve on Gitea 1.22.6 because [actions].DEFAULT_ACTIONS_URL=github routes the
# fetch to github.com (where `molecule-ai` is suspended). This file is a port,
# not a switch-on. Until the operator-host server-side flip lands (task #109
# actions/* mirror) consumers calling this via `uses:` will continue to no-op
# /404 even after switching to the .gitea/ path. Land this PR first; flip the
# server config + consumers in a follow-up batch.
name: Validate Workspace Template
on:
workflow_call:
# Defense-in-depth on the GITHUB_TOKEN scope. This workflow runs
# untrusted-by-design code from the calling template repo — pip
# installs the template's requirements.txt (post-install hooks),
# imports adapter.py, and `docker build`s the Dockerfile (RUN
# steps). Each of those primitives can execute arbitrary code with
# the token in env. Pinning `contents: read` means the worst a
# malicious template PR can do with the token is read public repo
# state — no write to issues, no push to branches, no comment-spam,
# no workflow re-trigger.
#
# Fork-PR lockdown (#135): the workflow splits into two jobs:
#
# validate-static — file-content checks only (secret scan, YAML
# parse, AST inspection of adapter.py without
# import). Always runs, including external fork
# PRs. Safe because no third-party code executes.
#
# validate-runtime — pip install requirements.txt + import
# adapter.py + docker build. SKIPPED on fork
# PRs because each step is arbitrary code
# execution from the template repo's perspective.
# Internal PRs and post-merge runs still get
# the full coverage.
#
# What this prevents: a malicious external PR can no longer
# crypto-mine on the runner, DNS-exfiltrate runner metadata, or
# attempt to read GitHub-Actions internal env via a setup.py
# postinstall hook. They still get static feedback (secret scan
# is the most important security check anyway).
#
# What this does NOT prevent: malicious template metadata that
# passes static checks. The runtime job catches those once the PR
# merges (or an internal contributor reposts the change), at which
# point branch protection on staging/main blocks the merge if
# runtime validation fails.
permissions:
contents: read
jobs:
validate-static:
name: Template validation (static)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Calling template repo (Dockerfile + config.yaml + adapter.py).
- uses: actions/checkout@v4
# Canonical validator script lives in molecule-ci, fetched fresh on
# every run. The previous setup expected `.molecule-ci/scripts/` to
# be vendored INTO each template repo, which drifted across the 8
# template repos as the validator evolved. Single source of truth
# eliminates that drift class entirely — every template runs the
# same canonical contract check on every CI run.
# Direct git-clone — see validate-plugin.yml for the rationale.
# Anonymous fetch of public molecule-ci, no actions/checkout idiosyncrasies.
- name: Fetch molecule-ci canonical scripts
run: git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git .molecule-ci-canonical
- uses: actions/setup-python@v5
with:
python-version: "3.11"
# Secret scan — the most important check. Always runs.
- name: Check for secrets
run: |
python3 - << 'PYEOF'
import os, re, sys
from pathlib import Path
PATTERNS = [
re.compile(r'''["']sk-ant-[a-zA-Z0-9]{50,}["']'''),
re.compile(r'''["']ghp_[a-zA-Z0-9]{36,}["']'''),
re.compile(r'''["']AKIA[A-Z0-9]{16}["']'''),
re.compile(r'''["'][a-zA-Z0-9/+=]{40}["']'''),
re.compile(r'''["']sk_test_[a-zA-Z0-9]{24,}["']'''),
re.compile(r'''["']Bearer\s+[a-zA-Z0-9_.-]{20,}["']'''),
re.compile(r'''ghp_[a-zA-Z0-9]{36,}'''),
re.compile(r'''sk-ant-[a-zA-Z0-9]{50,}'''),
]
SKIP_DIRS = {'.molecule-ci', '.git', 'node_modules', '__pycache__'}
EXTENSIONS = {'.yaml', '.yml', '.md', '.py', '.sh'}
def is_false_positive(line):
ctx = line.lower()
return '...' in ctx or '<example' in ctx or '</example' in ctx
root = Path(os.environ.get('GITHUB_WORKSPACE', '.'))
warnings = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for filename in filenames:
if Path(filename).suffix not in EXTENSIONS:
continue
filepath = Path(dirpath) / filename
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
for lineno, line in enumerate(f.readlines(), 1):
for pattern in PATTERNS:
for match in pattern.finditer(line):
if not is_false_positive(line):
warnings.append(f" {filepath}:{lineno}: {match.group(0)[:40]}...")
except Exception:
pass
if warnings:
print("::error::Potential secret found in committed files:")
for w in warnings:
print(w)
sys.exit(1)
else:
print("::notice::No secrets detected")
PYEOF
# Static-only validator — file existence checks, YAML parse,
# AST inspection of adapter.py (no import). Doesn't execute
# any third-party code; safe on fork PRs.
- run: pip install pyyaml -q
- run: python3 .molecule-ci-canonical/scripts/validate-workspace-template.py --static-only
validate-runtime:
name: Template validation (runtime)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: validate-static
# Skip when the PR comes from a fork — those are external,
# untrusted, and would let attackers run pip install / docker
# build / adapter.py import on our runner. Internal PRs (head
# repo == base repo, fork == false) and push events to internal
# branches both keep full coverage.
#
# github.event.pull_request.head.repo.fork is null for non-PR
# events (push, schedule, etc.) — defaults to running.
if: github.event.pull_request.head.repo.fork != true
steps:
- uses: actions/checkout@v4
# Direct git-clone — see validate-plugin.yml for the rationale.
# Anonymous fetch of public molecule-ci, no actions/checkout idiosyncrasies.
- name: Fetch molecule-ci canonical scripts
run: git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git .molecule-ci-canonical
- uses: actions/setup-python@v5
with:
python-version: "3.11"
# Cache pip against the calling repo's own requirements.txt
# (the file we install one step below). Pointing the cache key
# at the validator's own deps was decorative — pyyaml never
# changes, so the key never invalidated even when the template
# added a heavy dep like crewai.
cache: "pip"
cache-dependency-path: requirements.txt
- run: pip install pyyaml -q
# Install the template's runtime dependencies so the validator's
# `check_adapter_runtime_load()` can import adapter.py the same way
# the workspace container does at boot. Without this, a
# syntactically-valid adapter that ImportErrors on a missing
# transitive dep would build clean and crash on first user prompt.
# The fallback (no requirements.txt) installs the runtime alone so
# BaseAdapter is at least importable for the class-discovery check.
- if: hashFiles('requirements.txt') != ''
run: pip install -q -r requirements.txt
- if: hashFiles('requirements.txt') == ''
run: pip install -q molecule-ai-workspace-runtime
# Full validator — includes adapter.py import (exec_module).
- run: python3 .molecule-ci-canonical/scripts/validate-workspace-template.py
- name: Docker build smoke test
if: hashFiles('Dockerfile') != ''
run: |
# Graceful skip when the runner's job-container can't reach the
# Docker daemon (e.g. /var/run/docker.sock not mounted into the
# act job container, or the in-container uid not in the docker
# group). Without this guard, every workspace template's
# CI / validate stays red post-2026-05-10 even when the
# template's Dockerfile is fine — see molecule-ai/internal#222
# ("act_runner job containers need /var/run/docker.sock access")
# for the proper runner-config fix. When that lands, the `else`
# branch goes away by virtue of `docker info` succeeding again.
if ! docker info >/dev/null 2>&1; then
echo "::warning::docker daemon unreachable from runner job container — skipping Docker build smoke (runner-config gap, not a template issue). Fix: see molecule-ai/internal runner-docker-access issue."
exit 0
fi
docker build -t template-test . --no-cache 2>&1 | tail -5 && echo "✓ Docker build succeeded"
# Aggregator that emits a single `Template validation` check name —
# the caller's job (`validate:` in each template's ci.yml) plus this
# job's name produces `validate / Template validation`, which is what
# template-repo branch protection has historically required.
#
# Why it's needed: the workflow was refactored from one job into
# validate-static + validate-runtime (with matrix-suffixed display
# names) for fork-PR security. The matrix names never match the
# original required-check name, so PR auto-merge silently hung in
# BLOCKED forever on every template repo (caught while shipping
# fixes for the boot-smoke gate, openclaw#11 + hermes#29).
#
# `if: always()` so it reports out even when validate-static fails —
# without that, GitHub marks the aggregator as SKIPPED and branch
# protection still blocks because the required check never reports
# a final state.
#
# Fork-PR semantics: validate-runtime is intentionally skipped on
# fork PRs (security gate). Treat `skipped` as a pass for the
# aggregator on forks so static-only coverage doesn't make every
# external PR un-mergeable.
template-validation:
name: Template validation
runs-on: ubuntu-latest
needs: [validate-static, validate-runtime]
if: always()
timeout-minutes: 1
steps:
- name: Aggregate
run: |
static="${{ needs.validate-static.result }}"
runtime="${{ needs.validate-runtime.result }}"
echo "validate-static: $static"
echo "validate-runtime: $runtime"
if [ "$static" != "success" ]; then
echo "::error::validate-static did not succeed: $static"
exit 1
fi
if [ "$runtime" != "success" ] && [ "$runtime" != "skipped" ]; then
echo "::error::validate-runtime did not succeed: $runtime"
exit 1
fi
echo "::notice::Template validation aggregate passed (static=$static, runtime=$runtime)"