Compare commits
1 Commits
main
...
docs/readm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90b0095d3 |
@ -1,55 +0,0 @@
|
||||
name: 'Audit force-merge'
|
||||
description: >-
|
||||
§SOP-6 force-merge audit. Detects PRs merged with required-status-checks
|
||||
not green at HEAD SHA and emits incident.force_merge JSON to runner
|
||||
stdout. Vector docker_logs source ships the line to Loki on
|
||||
molecule-canonical-obs (per reference_obs_stack_phase1).
|
||||
|
||||
# Why a composite action and not a reusable workflow:
|
||||
# Gitea 1.22.6 does NOT support cross-repo `uses: org/repo/.gitea/
|
||||
# workflows/X.yml@ref`. Cross-repo reusable workflows landed in
|
||||
# go-gitea/gitea PR #32562 in Gitea 1.26.0 (Oct 2025). On 1.22.x the
|
||||
# clone fails because act_runner mints a caller-scoped GITEA_TOKEN.
|
||||
# Composite actions resolve via the actions-fetch path which works
|
||||
# cross-repo on 1.22 against a public callee — that's us. Re-evaluate
|
||||
# this choice when the operator host upgrades to Gitea ≥ 1.26.
|
||||
|
||||
inputs:
|
||||
gitea-token:
|
||||
description: >-
|
||||
PAT for sop-tier-bot (or equivalent read-only audit identity).
|
||||
Needs read:user,read:repository,read:issue scopes — admin scope
|
||||
is intentionally NOT required.
|
||||
required: true
|
||||
gitea-host:
|
||||
description: 'Gitea host'
|
||||
required: false
|
||||
default: 'git.moleculesai.app'
|
||||
repo:
|
||||
description: 'owner/name; typically ${{ github.repository }}'
|
||||
required: true
|
||||
pr-number:
|
||||
description: 'PR number; typically ${{ github.event.pull_request.number }}'
|
||||
required: true
|
||||
required-checks:
|
||||
description: >-
|
||||
Newline-separated required-status-check context names. Mirror
|
||||
of branch protection's status_check_contexts. Declared at the
|
||||
caller because /branch_protections requires admin scope which
|
||||
this audit identity intentionally does not hold (least-privilege).
|
||||
When the required-check set changes, update both branch
|
||||
protection AND this input.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect force-merge + emit audit event
|
||||
shell: bash
|
||||
env:
|
||||
GITEA_TOKEN: ${{ inputs.gitea-token }}
|
||||
GITEA_HOST: ${{ inputs.gitea-host }}
|
||||
REPO: ${{ inputs.repo }}
|
||||
PR_NUMBER: ${{ inputs.pr-number }}
|
||||
REQUIRED_CHECKS: ${{ inputs.required-checks }}
|
||||
run: bash "$GITHUB_ACTION_PATH/audit.sh"
|
||||
@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# audit-force-merge — detect a §SOP-6 force-merge on a closed PR, emit
|
||||
# `incident.force_merge` to stdout as structured JSON.
|
||||
#
|
||||
# Invoked by the `audit-force-merge` composite action defined alongside
|
||||
# this script (action.yml). Caller workflows fire on
|
||||
# `pull_request_target: closed` and gate on `merged == true`. See
|
||||
# action.yml for the supported inputs.
|
||||
#
|
||||
# Vector's docker_logs source picks up runner stdout; the JSON gets
|
||||
# shipped to Loki on molecule-canonical-obs, indexable by event_type.
|
||||
# Query example:
|
||||
#
|
||||
# {host="operator"} |= "event_type" |= "incident.force_merge" | json
|
||||
#
|
||||
# A force-merge is detected when a merged PR had at least one of the
|
||||
# caller-declared required-status-check contexts in a state other than
|
||||
# "success" at the PR HEAD. That's exactly what the Gitea
|
||||
# force_merge:true API call lets through, so it's a faithful detector
|
||||
# of the override path.
|
||||
#
|
||||
# Required env (set by the composite action via inputs):
|
||||
# GITEA_TOKEN, GITEA_HOST, REPO, PR_NUMBER, REQUIRED_CHECKS
|
||||
#
|
||||
# REQUIRED_CHECKS is newline-separated context names. Declared by the
|
||||
# caller (mirror of branch protection's status_check_contexts) rather
|
||||
# than fetched from /branch_protections, which requires admin scope —
|
||||
# the audit identity is intentionally read-only (least-privilege; see
|
||||
# memory/feedback_least_privilege_via_workflow_env).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${GITEA_TOKEN:?required}"
|
||||
: "${GITEA_HOST:?required}"
|
||||
: "${REPO:?required}"
|
||||
: "${PR_NUMBER:?required}"
|
||||
: "${REQUIRED_CHECKS:?required (newline-separated context names)}"
|
||||
|
||||
OWNER="${REPO%%/*}"
|
||||
NAME="${REPO##*/}"
|
||||
API="https://${GITEA_HOST}/api/v1"
|
||||
AUTH="Authorization: token ${GITEA_TOKEN}"
|
||||
|
||||
# 1. Fetch the PR. If not merged, no-op.
|
||||
PR=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
|
||||
MERGED=$(echo "$PR" | jq -r '.merged // false')
|
||||
if [ "$MERGED" != "true" ]; then
|
||||
echo "::notice::PR #${PR_NUMBER} closed without merge — no audit emission."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty')
|
||||
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"')
|
||||
TITLE=$(echo "$PR" | jq -r '.title // ""')
|
||||
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"')
|
||||
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty')
|
||||
|
||||
if [ -z "$MERGE_SHA" ]; then
|
||||
echo "::warning::PR #${PR_NUMBER} merged=true but no merge_commit_sha — cannot evaluate force-merge."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2. Required status checks declared in the workflow env.
|
||||
REQUIRED="$REQUIRED_CHECKS"
|
||||
if [ -z "${REQUIRED//[[:space:]]/}" ]; then
|
||||
echo "::notice::REQUIRED_CHECKS empty — force-merge not applicable."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3. Status-check state at the PR HEAD (where checks ran). The merge
|
||||
# commit doesn't get its own checks; we evaluate the PR's last
|
||||
# commit, which is what branch protection compared against.
|
||||
STATUS=$(curl -sS -H "$AUTH" \
|
||||
"${API}/repos/${OWNER}/${NAME}/commits/${HEAD_SHA}/status")
|
||||
declare -A CHECK_STATE
|
||||
while IFS=$'\t' read -r ctx state; do
|
||||
[ -n "$ctx" ] && CHECK_STATE[$ctx]="$state"
|
||||
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"')
|
||||
|
||||
# 4. For each required check, was it green at merge? YAML block scalars
|
||||
# (`|`) leave a trailing newline; skip blank/whitespace-only lines.
|
||||
FAILED_CHECKS=()
|
||||
while IFS= read -r req; do
|
||||
trimmed="${req#"${req%%[![:space:]]*}"}" # ltrim
|
||||
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" # rtrim
|
||||
[ -z "$trimmed" ] && continue
|
||||
state="${CHECK_STATE[$trimmed]:-missing}"
|
||||
if [ "$state" != "success" ]; then
|
||||
FAILED_CHECKS+=("${trimmed}=${state}")
|
||||
fi
|
||||
done <<< "$REQUIRED"
|
||||
|
||||
if [ "${#FAILED_CHECKS[@]}" -eq 0 ]; then
|
||||
echo "::notice::PR #${PR_NUMBER} merged with all required checks green — not a force-merge."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 5. Emit structured audit event.
|
||||
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .)
|
||||
|
||||
# Print as a single-line JSON so Vector's parse_json transform can pick
|
||||
# it up cleanly from docker_logs.
|
||||
jq -nc \
|
||||
--arg event_type "incident.force_merge" \
|
||||
--arg ts "$NOW" \
|
||||
--arg repo "$REPO" \
|
||||
--argjson pr "$PR_NUMBER" \
|
||||
--arg title "$TITLE" \
|
||||
--arg base "$BASE_BRANCH" \
|
||||
--arg merged_by "$MERGED_BY" \
|
||||
--arg merge_sha "$MERGE_SHA" \
|
||||
--argjson failed_checks "$FAILED_JSON" \
|
||||
'{event_type: $event_type, ts: $ts, repo: $repo, pr: $pr, title: $title,
|
||||
base_branch: $base, merged_by: $merged_by, merge_sha: $merge_sha,
|
||||
failed_checks: $failed_checks}'
|
||||
|
||||
echo "::warning::FORCE-MERGE detected on PR #${PR_NUMBER} by ${MERGED_BY}: ${#FAILED_CHECKS[@]} required check(s) not green at merge time."
|
||||
2
.github/workflows/auto-promote-branch.yml
vendored
2
.github/workflows/auto-promote-branch.yml
vendored
@ -21,7 +21,7 @@ name: Auto-promote branch (reusable)
|
||||
# administration: read # read branch protection (REQUIRED — see below)
|
||||
# jobs:
|
||||
# promote:
|
||||
# uses: molecule-ai/molecule-ci/.github/workflows/auto-promote-branch.yml@v1
|
||||
# uses: Molecule-AI/molecule-ci/.github/workflows/auto-promote-branch.yml@v1
|
||||
# with:
|
||||
# from-branch: staging
|
||||
# to-branch: main
|
||||
|
||||
@ -28,7 +28,7 @@ name: Auto-promote staging → main (PR-based, reusable)
|
||||
# pull-requests: write
|
||||
# jobs:
|
||||
# promote:
|
||||
# uses: molecule-ai/molecule-ci/.github/workflows/auto-promote-staging-pr.yml@v1
|
||||
# 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' }}
|
||||
@ -230,7 +230,7 @@ jobs:
|
||||
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.
|
||||
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
|
||||
|
||||
2
.github/workflows/auto-promote-staging.yml
vendored
2
.github/workflows/auto-promote-staging.yml
vendored
@ -4,7 +4,7 @@ name: Auto-promote staging → main
|
||||
# `auto-promote-branch.yml` workflow factored out for org-wide reuse.
|
||||
# Other repos consume the same reusable workflow via:
|
||||
#
|
||||
# uses: molecule-ai/molecule-ci/.github/workflows/auto-promote-branch.yml@v1
|
||||
# uses: Molecule-AI/molecule-ci/.github/workflows/auto-promote-branch.yml@v1
|
||||
#
|
||||
# Excluded by policy: molecule-core + molecule-controlplane stay
|
||||
# manual per CEO directive 2026-04-24. Those repos do NOT call the
|
||||
|
||||
@ -22,7 +22,7 @@ name: Disable auto-merge on push
|
||||
# pull-requests: write
|
||||
# jobs:
|
||||
# disable-auto-merge-on-push:
|
||||
# uses: molecule-ai/molecule-ci/.github/workflows/disable-auto-merge-on-push.yml@v1
|
||||
# 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
|
||||
|
||||
4
.github/workflows/publish-template-image.yml
vendored
4
.github/workflows/publish-template-image.yml
vendored
@ -1,6 +1,6 @@
|
||||
name: Publish Workspace Template Image
|
||||
|
||||
# Reusable workflow for every molecule-ai/molecule-ai-workspace-template-*
|
||||
# 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
|
||||
@ -17,7 +17,7 @@ name: Publish Workspace Template Image
|
||||
# packages: write
|
||||
# jobs:
|
||||
# publish:
|
||||
# uses: molecule-ai/molecule-ci/.github/workflows/publish-template-image.yml@v1
|
||||
# uses: Molecule-AI/molecule-ci/.github/workflows/publish-template-image.yml@v1
|
||||
# secrets: inherit
|
||||
#
|
||||
# Runner choice (2026-04-22): ubuntu-latest
|
||||
|
||||
8
.github/workflows/validate-org-template.yml
vendored
8
.github/workflows/validate-org-template.yml
vendored
@ -15,10 +15,10 @@ jobs:
|
||||
# 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/checkout@v4
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ci
|
||||
path: .molecule-ci-canonical
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
17
.github/workflows/validate-plugin.yml
vendored
17
.github/workflows/validate-plugin.yml
vendored
@ -15,19 +15,10 @@ jobs:
|
||||
# 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/checkout@v4
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ci
|
||||
path: .molecule-ci-canonical
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
@ -54,10 +54,10 @@ jobs:
|
||||
# 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/checkout@v4
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ci
|
||||
path: .molecule-ci-canonical
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
@ -133,10 +133,10 @@ jobs:
|
||||
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/checkout@v4
|
||||
with:
|
||||
repository: Molecule-AI/molecule-ci
|
||||
path: .molecule-ci-canonical
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
@ -2,47 +2,19 @@
|
||||
"""Validate a Molecule AI org template repo."""
|
||||
import os, sys, yaml
|
||||
|
||||
# Support custom YAML tags used by org templates. Two shapes:
|
||||
#
|
||||
# - `!include teams/pm.yaml` → scalar string referencing another YAML
|
||||
# file in the same repo. Platform inlines at load time.
|
||||
#
|
||||
# - `!external\n repo: ...\n ref: ...\n path: ...` → mapping
|
||||
# referencing a workspace tree to fetch from another repo. Platform
|
||||
# fetches into a content-addressable cache at load time
|
||||
# (internal#77 / molecule-core#105).
|
||||
#
|
||||
# Both shapes resolve at platform load time, not at validation time.
|
||||
# The validator treats them as opaque references — it does NOT chase
|
||||
# them down. We mark each parsed value with a sentinel subtype so the
|
||||
# `validate_workspace` walk knows to skip them rather than tripping
|
||||
# the "missing 'name'" branch.
|
||||
class IncludeRef(str):
|
||||
"""`!include path/to.yaml` — opaque reference, skipped by validator."""
|
||||
|
||||
class ExternalRef(dict):
|
||||
"""`!external` mapping — opaque reference, skipped by validator."""
|
||||
|
||||
# Support !include and other custom YAML tags used by org templates.
|
||||
# These resolve at platform load time, not at validation time — we just
|
||||
# need to parse past them without crashing.
|
||||
class PermissiveLoader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
def _include_constructor(loader, node):
|
||||
return IncludeRef(loader.construct_scalar(node))
|
||||
|
||||
def _external_constructor(loader, node):
|
||||
return ExternalRef(loader.construct_mapping(node))
|
||||
|
||||
def _generic_constructor(loader, tag_suffix, node):
|
||||
# Fallback for unknown tags. Preserve the parsed shape so legacy
|
||||
# docs that lean on tags we have not modeled yet still parse.
|
||||
if isinstance(node, yaml.MappingNode):
|
||||
return loader.construct_mapping(node)
|
||||
if isinstance(node, yaml.SequenceNode):
|
||||
return loader.construct_sequence(node)
|
||||
return loader.construct_scalar(node)
|
||||
|
||||
PermissiveLoader.add_constructor("!include", _include_constructor)
|
||||
PermissiveLoader.add_constructor("!external", _external_constructor)
|
||||
PermissiveLoader.add_multi_constructor("!", _generic_constructor)
|
||||
|
||||
errors = []
|
||||
@ -61,13 +33,7 @@ if not org.get("workspaces") and not org.get("defaults"):
|
||||
errors.append("org.yaml must have at least 'workspaces' or 'defaults'")
|
||||
|
||||
def validate_workspace(ws, path=""):
|
||||
# `!include path/to.yaml` parses as IncludeRef (str subclass).
|
||||
# `!external {repo, ref, path}` parses as ExternalRef (dict subclass).
|
||||
# Both are opaque references — skip without chasing.
|
||||
if isinstance(ws, (IncludeRef, ExternalRef)):
|
||||
return []
|
||||
# Legacy unknown-tag scalars (handled by _generic_constructor) stay
|
||||
# as plain strings; they are not workspace dicts either.
|
||||
# !include tags resolve to strings at parse time; skip non-dicts
|
||||
if not isinstance(ws, dict):
|
||||
return []
|
||||
ws_errors = []
|
||||
@ -93,11 +59,6 @@ if errors:
|
||||
def count_ws(nodes):
|
||||
c = 0
|
||||
for n in nodes:
|
||||
# Skip opaque references — we do not know how many workspaces
|
||||
# they expand to without resolving them, and resolution is the
|
||||
# platform's job, not the validator's.
|
||||
if isinstance(n, (IncludeRef, ExternalRef)):
|
||||
continue
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
c += 1
|
||||
@ -105,4 +66,4 @@ def count_ws(nodes):
|
||||
return c
|
||||
|
||||
total = count_ws(org.get("workspaces", []))
|
||||
print(f"✓ org.yaml valid: {org['name']} ({total} direct workspaces; external refs not counted)")
|
||||
print(f"✓ org.yaml valid: {org['name']} ({total} workspaces)")
|
||||
|
||||
62
README.md
62
README.md
@ -37,6 +37,26 @@ jobs:
|
||||
uses: Molecule-AI/molecule-ci/.github/workflows/validate-org-template.yml@v1
|
||||
```
|
||||
|
||||
### Workspace template repos publishing to GHCR
|
||||
|
||||
```yaml
|
||||
# .github/workflows/publish-image.yml
|
||||
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
|
||||
```
|
||||
|
||||
Also fires from the runtime-publish cascade (`repository_dispatch`) so a fresh `molecule-ai-workspace-runtime` PyPI release auto-rebuilds every template image.
|
||||
|
||||
### Any repo with auto-merge enabled
|
||||
|
||||
PR-time guards (currently: disable auto-merge on follow-up push). Consume from a thin caller:
|
||||
@ -108,6 +128,48 @@ Together they cover the full lifecycle of "auto-merge enabled → new commits ar
|
||||
|
||||
**False-positive note:** if a CI bot pushes (dependency update, secret rotation), this also disables auto-merge. That's intentional — the operator who originally enabled auto-merge gets notified and re-engages, which is exactly the verify-after-machine-edits behavior we want.
|
||||
|
||||
## publish-template-image
|
||||
|
||||
Builds + publishes Docker template images for workspace runtimes to GHCR (`ghcr.io/molecule-ai/workspace-template-<runtime>:latest` plus a per-commit `:sha-<7>` tag). Auto-derives `<runtime>` from the caller repo name (`molecule-ai-workspace-template-<runtime>`).
|
||||
|
||||
**Triggers** (caller-side `on:` block):
|
||||
|
||||
| Event | When | Source |
|
||||
|---|---|---|
|
||||
| `push` to `main` | Template Dockerfile / config / adapter changes | Caller commit |
|
||||
| `workflow_dispatch` | Manual rebuild | Operator |
|
||||
| `repository_dispatch` (cascade) | New `molecule-ai-workspace-runtime` PyPI release | molecule-core `publish-runtime.yml` fans out to every template repo |
|
||||
|
||||
**Inputs** (all optional):
|
||||
|
||||
| Input | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `runtime_name` | derived from repo name | Override only when image should diverge from `molecule-ai-workspace-template-<runtime>` convention |
|
||||
| `runtime_version` | empty (Dockerfile pin wins) | Forwarded as `RUNTIME_VERSION` build-arg → unique cache key per version. Cascade builds set this to the just-published wheel version so each rebuild gets a fresh `pip install`. |
|
||||
|
||||
**Secrets:** `secrets: inherit` (uses caller's `GITHUB_TOKEN` for GHCR push — no custom secrets needed).
|
||||
|
||||
**Outputs:** `image` (full ref pushed) and `sha` (short tag).
|
||||
|
||||
**Pipeline order** (each step is a publish gate — fail = no GHCR push):
|
||||
|
||||
1. **Lint** — bare imports of runtime modules (e.g. `from plugins import ...` instead of `from molecule_runtime.plugins import ...`). Module list pulled live from the latest wheel's `_runtime_modules.json` so the lint never drifts from the rewriter. Catches the 2026-04-27 5-template ImportError outage class.
|
||||
2. **Static import smoke** — boots the image and `import`s every `/app/*.py`, exercising adapter-level module-load failures and runtime version skew.
|
||||
3. **Boot smoke** (`MOLECULE_SMOKE_MODE=1`) — actually runs `executor.execute()` against stub deps + stub creds, catching **lazy imports** buried inside `async def execute(...)` bodies that the static smoke can't see (the a2a-sdk v0→v1 migration shipped 5 such regressions). Also consults `runtime_wedge.is_wedged()` to upgrade provisional PASS to FAIL when an adapter marked the runtime wedged.
|
||||
4. **Push to GHCR** — only after all three gates pass.
|
||||
|
||||
**Smoke timeout calibration** (load-bearing — do not lower without re-testing with an injected wedge):
|
||||
|
||||
- `MOLECULE_SMOKE_TIMEOUT_SECS=90` — inner timeout. Outlasts claude-agent-sdk's 60s `initialize()` handshake so the adapter's wedge-catch arm runs **before** smoke gives up. Lowering this back to the original 10s blinds the gate to PR-25-class init-wedge bugs.
|
||||
- `timeout 120` outer wrapper — runner-level safety net; surfaces `exit 124` (smoke_mode itself wedged) as a distinct error from `exit 1` (adapter ImportError / wedge).
|
||||
- 90s/120s pair landed in [PR #33](https://github.com/Molecule-AI/molecule-ci/pull/33) (2026-05-02) for SDK-init-wedge coverage. The workflow's inline comment is the source of truth — read it before changing.
|
||||
|
||||
**Cross-references:**
|
||||
|
||||
- Boot-smoke contract + wedge protocol: `molecule-core/workspace/smoke_mode.py` docstring
|
||||
- Cascade trigger (runtime publish → template rebuild fan-out): `molecule-core/.github/workflows/publish-runtime.yml`
|
||||
- Why this gate exists at all (original outage post-mortem): the 2026-04-27 `RuntimeCapabilities` ImportError that shipped to `:latest` because the old smoke only inspected the entrypoint string
|
||||
|
||||
## License
|
||||
|
||||
Business Source License 1.1 — © Molecule AI.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user