Skip to content

NavisCoord 0.4.1

NavisCoord 0.4.1 #50

Workflow file for this run

name: CI
# What can and cannot be checked here, and why.
#
# The engine is pure Python and runs anywhere, so it is tested on every push
# across every supported interpreter and both mcp majors.
#
# The add-in links against the Navisworks .NET API, which ships only with a
# licensed Autodesk installation and cannot be redistributed to a runner — so
# the full add-in is NOT built here. What is built and tested is everything in
# it that carries no Autodesk reference, which is deliberately most of the
# logic that ever had a bug: rule precedence, level naming, repeat detection,
# view purity, the mutation envelope, the job state machine, the path policy,
# the profile schema, the session store and capabilities.
#
# Building the real add-in for 2024/2025/2026 is a manual step before a
# release; see docs/RELEASING.md.
# A release tag used to trigger nothing at all. The suite carries the one rule
# that is ABOUT tags — does this commit's ref name a tag that exists, and is it
# the tag being built — and the only moment that question is finally decidable,
# the push of the tag itself, was the single event the workflow did not listen
# for. The rule was therefore never executed where it means the most, and a tag
# could publish a ref nobody had checked.
# Ningún job publica nada: leer el repositorio es todo lo que necesitan, y es
# todo lo que el token puede hacer. Un permiso que no existe no se puede
# filtrar por un action comprometido.
permissions:
contents: read
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
# Lets a specific ref be re-checked without pushing anything — the diagnostic
# path that did not exist when a tag's CI needed to be inspected after the
# fact.
workflow_dispatch:
jobs:
engine:
name: motor (py${{ matrix.python }}, mcp${{ matrix.mcp }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
# Both mcp majors are supported and both must stay supported. Testing
# only what a developer happens to have pinned is how mcp 2.0 shipped
# a broken fresh install: the code was fine on every machine that
# already had 1.x, and unusable on every machine that did not.
mcp: ["<2", ">=2,<3"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# La suite comprueba a qué tag apuntan los manifiestos, y esa
# pregunta no se puede responder con una lista de tags incompleta.
# El checkout por defecto es superficial y sin tags: `git tag` vuelve
# vacío y el pin concluiría que el tag no existe cuando sí existe.
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python }}
- name: Instalar
run: |
cd server
pip install -e ".[dev]"
pip install "mcp${{ matrix.mcp }}"
- name: mcp resuelto
run: pip show mcp | head -2
- name: Pruebas
run: cd server && python -m pytest tests -q
engine-minimums:
# pyproject declara mcp>=1.9, reportlab>=4.0, pillow>=10.0. Nada de eso es
# verdad si nadie lo ejecuta: la matriz normal resuelve las versiones MAS
# NUEVAS dentro del rango, así que el mínimo declarado puede llevar roto
# meses sin que ningún job lo note. Este job instala exactamente los
# suelos y corre la misma suite.
name: motor con los mínimos declarados
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
# El Python más viejo soportado con las dependencias más viejas
# soportadas: la esquina donde una API "nueva" usada por descuido
# revienta primero.
python-version: "3.10"
- name: Instalar los suelos exactos
run: |
cd server
pip install -e ".[dev]"
pip install "mcp==1.9.*" "reportlab==4.0.*" "pillow==10.0.*"
- name: Versiones resueltas
run: pip show mcp reportlab pillow | grep -E "^(Name|Version)"
- name: Pruebas
run: cd server && python -m pytest tests -q
windows-engine:
# The path policy rejects UNC paths, device namespaces, reserved DOS
# names and NTFS alternate data streams — every one of which is a Windows
# concept, and the platform the tool actually runs on.
name: motor en Windows
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# La suite comprueba a qué tag apuntan los manifiestos, y esa
# pregunta no se puede responder con una lista de tags incompleta.
# El checkout por defecto es superficial y sin tags: `git tag` vuelve
# vacío y el pin concluiría que el tag no existe cuando sí existe.
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Instalar
run: |
cd server
pip install -e ".[dev]"
- name: Pruebas
run: cd server; python -m pytest tests -q
addin-logic:
name: lógica del add-in (sin licencia)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: "8.0.x"
- name: Pruebas C#
run: dotnet run --project addin/NavisCoord.Tests
- name: Autopruebas de los scripts de release
# Aqui y no en el job de higiene: estos scripts codifican semantica de
# rutas de WINDOWS -unidades, UNC, flujos alternativos NTFS, reparse
# points-. Ejercitarlos en un runner Linux probaria otra cosa y daria
# un verde que no significa nada. Son logica pura sobre rutas: no
# necesitan Navisworks ni tocan nada del sistema.
shell: pwsh
run: |
$bad = 0
foreach ($script in @('NavisworksSafety', 'RestoreSafety', 'FindNavisworks', 'Build-Release', 'Smoke-AddinSwap')) {
& "./scripts/$script.ps1" -SelfTest
if ($LASTEXITCODE -ne 0) { $bad = 1 }
}
# El ciclo completo contra raices temporales: llama al nucleo
# transaccional, no al entrypoint, asi que corre sin Navisworks y
# sin desactivar la compuerta.
& ./scripts/Test-SmokeRestore.ps1
if ($LASTEXITCODE -ne 0) { $bad = 1 }
exit $bad
packaging:
name: wheel, sdist e instalación limpia
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# La suite comprueba a qué tag apuntan los manifiestos, y esa
# pregunta no se puede responder con una lista de tags incompleta.
# El checkout por defecto es superficial y sin tags: `git tag` vuelve
# vacío y el pin concluiría que el tag no existe cuando sí existe.
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Herramienta de build
run: pip install build
- name: Construir y verificar artefactos
# Reads both archives back and asserts LICENSE, NOTICE, the default
# profile and the MCP server are inside. A build step that copies
# files and trusts the result is how the problem returns.
run: python scripts/build_artifacts.py
- name: El sdist se basta a si mismo
# Reconstruye el wheel DESDE el sdist extraido y compara. Un sdist al
# que le falte pyproject.toml, el perfil o el README se descarga bien
# y falla al construir, meses despues y en la maquina de otro.
run: python scripts/verify_sdist.py --strict
- name: Dos construcciones del mismo arbol dan los mismos bytes
# Reproducibilidad medida, no afirmada. setuptools honra
# SOURCE_DATE_EPOCH en el wheel pero no en el sdist, asi que esto es
# lo unico que dice si normalize_sdist sigue funcionando.
run: python scripts/build_artifacts.py --reproducible
- name: Instalar DESDE EL WHEEL, no editable
# An editable install hides every packaging defect: the profile used
# to live one level above the package and worked only because every
# install so far had been editable.
run: |
python -m venv /tmp/clean
/tmp/clean/bin/python -m pip install --quiet server/dist/*.whl
- name: El paquete funciona instalado
run: |
/tmp/clean/bin/python - <<'PY'
import naviscoord
from naviscoord.profile import Profile
from naviscoord.mcp_server import mcp
assert Profile.load().name == "default", "el perfil no viajó dentro del paquete"
print("naviscoord", naviscoord.__version__, "instalado desde wheel: ok")
PY
- name: Los console scripts existen
run: |
test -x /tmp/clean/bin/naviscoord
test -x /tmp/clean/bin/naviscoord-mcp
- name: LICENSE y NOTICE llegan a la instalación
run: |
find /tmp/clean -path "*naviscoord*dist-info*" -name LICENSE | grep -q .
find /tmp/clean -path "*naviscoord*dist-info*" -name NOTICE | grep -q .
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: dist
path: server/dist/*
manifests:
name: manifiestos y coherencia de versiones
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Tags and full history, because the marketplace pin is a rule ABOUT
# tags. Without them `git tag` came back empty, the check could not
# tell "no tags exist" from "nobody fetched them", and it skipped —
# reporting success for every run since it was written.
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
- name: Coherencia de versiones y forma de los manifiestos
# Same assertions the suite makes, run without installing the package
# so a packaging break cannot hide a manifest break.
run: |
cd server
pip install --quiet pytest
python -m pytest tests/test_packaging.py -q
- name: El ref de los marketplaces corresponde a la fase
# Run standalone as well as through pytest: this is the check that
# decides whether a tag would publish the right ref, and it must be
# legible in the log rather than buried in a dot.
run: python scripts/marketplace_pin.py --explain
- name: La fase detectada es la que corresponde a este evento
# The check above reports the phase it decided on; nothing until now
# confirmed that phase was the RIGHT one for the event that started
# the run. A tag build that quietly evaluated itself as `dev` would
# pass every assertion above and prove nothing about the tag — the
# same shape of silent success this whole rule exists to remove.
run: |
python - <<'PY'
import os, sys, pathlib
sys.path.insert(0, str(pathlib.Path("scripts").resolve()))
import marketplace_pin as pin
ref = os.environ.get("GITHUB_REF", "")
event = os.environ.get("GITHUB_EVENT_NAME", "")
if ref.startswith("refs/tags/"):
expected = pin.TAG
elif event == "pull_request":
expected = pin.PRETAG if (os.environ.get("GITHUB_HEAD_REF", "")
.startswith("release/naviscoord-")) else pin.DEV
elif os.environ.get("GITHUB_REF_NAME") == pin.DEFAULT_BRANCH:
expected = pin.MAIN
else:
expected = pin.DEV
got = pin.detect_phase(dict(os.environ))
print(f"evento={event} ref={ref} head_ref={os.environ.get('GITHUB_HEAD_REF','')}")
print(f"fase esperada={expected} fase detectada={got}")
if got != expected:
print(f"::error::el run se evaluo en fase '{got}' cuando el evento "
f"corresponde a '{expected}'")
raise SystemExit(1)
# In the tag phase the tag list is the prerequisite of the whole
# rule; an empty one here means the checkout did not bring tags and
# every tag assertion below would be measuring nothing.
if got == pin.TAG:
tags, available = pin.local_tags(pathlib.Path("."))
if not available or not tags:
print("::error::fase tag sin lista de tags: el checkout no los trajo")
raise SystemExit(1)
print(f"tags visibles en el checkout: {sorted(tags)}")
print("fase: ok")
PY
- name: Validador real de plugins
run: |
# Fijado por versión exacta: un `install -g` sin versión valida cada
# run con una herramienta distinta, y un cambio del validador se
# confunde con un cambio del plugin.
npm install -g @anthropic-ai/claude-code@2.1.237
claude plugin validate . --strict
- name: Validar la política del marketplace de Codex
run: |
python - <<'PY'
import json
from pathlib import Path
path = Path(".agents/plugins/marketplace.json")
data = json.loads(path.read_text(encoding="utf-8"))
assert data.get("name"), "falta name"
assert data.get("plugins"), "falta plugins"
for plugin in data["plugins"]:
assert plugin.get("name"), "plugin sin name"
assert plugin.get("category"), "plugin sin category"
policy = plugin.get("policy") or {}
assert policy.get("installation") in {
"NOT_AVAILABLE", "AVAILABLE", "INSTALLED_BY_DEFAULT"
}, "policy.installation inválida"
assert policy.get("authentication") in {
"ON_INSTALL", "ON_USE"
}, "policy.authentication inválida"
print("marketplace Codex: ok")
PY
- name: Validar el manifiesto del plugin en aislamiento
run: |
mkdir -p /tmp/plug/.claude-plugin
cp .claude-plugin/plugin.json /tmp/plug/.claude-plugin/
cp -r skills scripts /tmp/plug/
claude plugin validate /tmp/plug --strict
public-hygiene:
# Some identifiers belong to internal deployments and must never appear
# here: project codes, shared-parameter prefixes, deployment-specific
# names. The realistic failure is not malice but a routine copy that
# brings one across — and once that is pushed it is public forever.
#
# The obvious guard is a list of the forbidden words, and it cannot be used
# here: the list would publish them, in this file and in every CI log that
# echoes the step. So the terms live as salted SHA-256 digests in
# .github/markers.denylist and a failure names file and line but never the
# value. See scripts/check_markers.py.
name: higiene pública
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# La suite comprueba a qué tag apuntan los manifiestos, y esa
# pregunta no se puede responder con una lista de tags incompleta.
# El checkout por defecto es superficial y sin tags: `git tag` vuelve
# vacío y el pin concluiría que el tag no existe cuando sí existe.
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Ningún marcador interno en el árbol
run: python scripts/check_markers.py
- name: El escáner de marcadores detecta lo que dice detectar
# Exercised with INVENTED markers. A guard nobody tests is a guard that
# quietly stops working, and this one cannot be eyeballed: its list is
# a set of digests.
run: python scripts/check_markers.py --selftest
- name: Ninguna ruta personal ni secreto evidente
shell: bash
run: |
set -uo pipefail
status=0
# C:\Users\<someone> with a real name, not a placeholder.
hits=$(grep -rIinE 'C:\\+Users\\+[A-Za-z0-9._-]+' --exclude-dir=.git . \
| grep -viE '<[a-z-]+>|%USERNAME%|\$env:USERNAME|USERPROFILE|tu-usuario' || true)
if [ -n "$hits" ]; then
echo "::error::ruta personal:"; echo "$hits"; status=1
fi
hits=$(grep -rIinE 'ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----' \
--exclude-dir=.git . || true)
if [ -n "$hits" ]; then
echo "::error::posible secreto:"; echo "$hits"; status=1
fi
exit $status
- name: Ningún modelo ni export de cliente versionado
shell: bash
run: |
set -uo pipefail
hits=$(git ls-files | grep -iE '\.(nwd|nwf|nwc|nwfacc|rvt|rfa|ifc|dwg)$' || true)
if [ -n "$hits" ]; then
echo "::error::archivos de modelo versionados:"; echo "$hits"; exit 1
fi
# Anything unexpectedly large is worth a human look before it is
# published; the repo is source and documentation only.
big=$(git ls-files | while read -r f; do
[ -f "$f" ] && [ "$(stat -c%s "$f")" -gt 2000000 ] && echo "$f $(stat -c%s "$f")"
done || true)
if [ -n "$big" ]; then
echo "::error::archivos >2 MB versionados:"; echo "$big"; exit 1
fi
- name: Ningún efímero de un run anterior está versionado
# Los archivos que los checks crean al vuelo (el driver del modo
# degradado, marcadores de sandbox, stagings) mueren con su run. Si
# uno aparece rastreado es que alguien hizo `git add .` después de una
# verificación fallida — y varios llevan dentro rutas de la máquina.
shell: bash
run: |
set -uo pipefail
hits=$(git ls-files | grep -E '(^|/)degraded_driver\.py$|(^|/)degraded\.jsonl$|\.staging-|\.naviscoord-(sandbox|selftest|repro|build-workspace)$' || true)
if [ -n "$hits" ]; then
echo "::error::efímeros versionados:"; echo "$hits"; exit 1
fi
echo "ningún efímero rastreado"
- name: Los scripts de Python compilan
# `install.ps1` y los .ps1 de release ya se parseaban; los .py de
# scripts/ no. Dos de ellos no los ejecuta nada en CI —
# `profile_checksums.py`, del que sale el checksum que el runner de C#
# compara, y `Smoke-AddinSwap.ps1` en su lado— así que un error de
# sintaxis ahí no aparecía hasta el siguiente release, hecho a mano.
run: |
python -m compileall -q scripts
python scripts/profile_checksums.py
- name: Los scripts de release son sintácticamente válidos
shell: pwsh
run: |
$bad = 0
foreach ($f in Get-ChildItem -Path scripts -Filter *.ps1 -Recurse) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) {
Write-Output "::error::$($f.Name): $($errors[0].Message)"
$bad = 1
} else {
Write-Output "ok $($f.Name)"
}
}
exit $bad
- name: El guard de artefactos detecta lo que dice detectar
# The add-in is built outside CI, against a licensed Autodesk API, so
# the guard that keeps the builder's filesystem out of the published
# binary cannot run here on a real DLL. Its DETECTION is exercised
# here instead, against synthetic files: a guard nobody tests is a
# guard that quietly stops working, which is how the leak it now
# catches survived three releases.
shell: pwsh
run: ./scripts/Assert-PublicArtifacts.ps1 -SelfTest
launcher:
name: launcher en modo degradado
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# La suite comprueba a qué tag apuntan los manifiestos, y esa
# pregunta no se puede responder con una lista de tags incompleta.
# El checkout por defecto es superficial y sin tags: `git tag` vuelve
# vacío y el pin concluiría que el tag no existe cuando sí existe.
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: El fallback responde en vez de reventar
# The degraded server shipped calling a function that had been
# deleted, so the one code path whose job is to explain a failure
# raised NameError instead. Driven here as a real JSON-RPC
# conversation, with NO dependencies installed.
shell: bash
run: |
# The driver goes in a FILE. It used to be fed to `python -` from a
# heredoc while the JSON-RPC lines were piped in — two things
# claiming the same stdin, so python read the script and the server
# then read end-of-file. It answered nothing, every time, on both
# runners: the assertion below was measuring the harness rather
# than the launcher.
cat > degraded_driver.py <<'PY'
import sys, pathlib
sys.path.insert(0, str(pathlib.Path("scripts").resolve()))
import plugin_launcher as L
# Exit code 1 is CORRECT here: the degraded server reports failure.
sys.exit(L.serve_failure("simulado: runtime ausente"))
PY
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize"}' \
'{"jsonrpc":"2.0","method":"initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"navis_install_status"}}' \
| python degraded_driver.py > degraded.jsonl || true
echo "--- respuestas ---"; cat degraded.jsonl
python - <<'PY'
import json
lines = [json.loads(l) for l in open("degraded.jsonl") if l.strip()]
assert len(lines) == 3, f"esperaba 3 respuestas (la notificación no lleva), hubo {len(lines)}"
assert lines[0]["result"]["serverInfo"]["name"] == "horizun-navis-mcp"
assert lines[0]["result"]["serverInfo"]["version"] != "unknown", "no leyó la versión real"
assert [t["name"] for t in lines[1]["result"]["tools"]] == ["navis_install_status"]
assert lines[2]["result"]["isError"] is True
payload = json.loads(lines[2]["result"]["content"][0]["text"])
assert "simulado" in payload["detail"]
# No se busca "pip install": el launcher dejó de aconsejarlo a
# propósito, porque instalar los rangos públicos es justo lo que
# el lock existe para evitar. Lo que tiene que seguir siendo
# cierto es que el mensaje diga cómo crear el entorno y dónde
# está el lock del que sale.
assert "-m venv" in payload["arreglo"], payload["arreglo"]
assert payload["lock"] in payload["arreglo"], "el arreglo no dice dónde está el lock"
print("modo degradado: ok")
PY
release-evidence:
# Solo en tags. Produce la evidencia que acompaña a una release —
# checksums, SBOM y procedencia — construida desde el SHA exacto que el
# tag nombra (actions/checkout trae github.sha, no la punta de una rama).
# NO publica nada: sube los archivos como artifacts del run para que la
# persona que hace la release los adjunte tras revisarlos. Crear la
# release sigue siendo un acto humano y deliberado.
name: evidencia de release (solo tags)
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
needs: [engine, packaging, manifests, public-hygiene]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: El tag apunta a este SHA
# La regla F1 entera: lo que se empaqueta es lo que el tag nombra.
run: |
tag="${GITHUB_REF#refs/tags/}"
resolved=$(git rev-parse "refs/tags/${tag}^{commit}")
echo "tag=${tag} resuelve=${resolved} sha del run=${GITHUB_SHA}"
test "$resolved" = "$GITHUB_SHA"
- name: Construir los artefactos de Python desde el SHA del tag
run: |
pip install build cyclonedx-bom==7.3.1
python scripts/build_artifacts.py
- name: SBOM (CycloneDX) del paquete construido
run: |
python -m venv /tmp/sbom-env
/tmp/sbom-env/bin/pip install --quiet server/dist/*.whl
cyclonedx-py environment /tmp/sbom-env --output-format JSON --output-file server/dist/sbom.cdx.json
python - <<'PY'
import json
doc = json.load(open("server/dist/sbom.cdx.json"))
names = {c.get("name") for c in doc.get("components", [])}
assert "naviscoord" in names, "el SBOM no contiene el propio paquete"
print(f"SBOM: {len(names)} componentes")
PY
- name: Checksums y procedencia
run: |
cd server/dist
python - <<'PY'
import json, os, time
json.dump({
"schema": "naviscoord.provenance/1",
"tag": os.environ["GITHUB_REF"].removeprefix("refs/tags/"),
"commit": os.environ["GITHUB_SHA"],
"repository": os.environ["GITHUB_REPOSITORY"],
"workflow_run": os.environ["GITHUB_RUN_ID"],
"runner": os.environ["RUNNER_OS"],
"built_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}, open("provenance.json", "w"), indent=2)
PY
# Nombres explícitos, no `*`: así SHA256SUMS nunca puede terminar
# intentando incluirse a sí mismo, y un archivo inesperado en dist
# rompe aquí en vez de firmarse sin querer.
sha256sum *.whl *.tar.gz sbom.cdx.json provenance.json > SHA256SUMS
cat SHA256SUMS provenance.json
- name: Los artefactos no nombran la máquina que los construyó
shell: pwsh
run: |
# El guard de PowerShell mira DLL; a los archivos de Python los
# revisa build_artifacts.py en la construcción. Aquí, la última
# línea de defensa: ninguna ruta del runner en los textos.
$bad = Select-String -Path server/dist/SHA256SUMS, server/dist/provenance.json `
-Pattern '/home/runner|/opt/hostedtoolcache|C:\a\' -SimpleMatch:$false
if ($bad) { Write-Error "ruta del runner en la evidencia: $bad"; exit 1 }
Write-Output "evidencia limpia"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-evidence
path: |
server/dist/*.whl
server/dist/*.tar.gz
server/dist/SHA256SUMS
server/dist/sbom.cdx.json
server/dist/provenance.json
# ---------------------------------------------------------------- ci-ok
#
# One stable name that means "everything passed", and the only check branch
# protection should require.
#
# The alternative is registering all fifteen job names as required, and
# thirteen of them carry the matrix inside the name — `motor (py3.10,
# mcp<2)` and so on. The day the matrix gains 3.14 or drops an mcp range,
# the required check stops existing, nothing ever reports it, and every pull
# request blocks on a status that can never arrive. Requiring one aggregate
# keeps the matrix free to change without touching repository settings.
#
# `if: always()` is what makes it meaningful: without it the job is skipped
# when a dependency fails, and a skipped required check blocks the PR with
# no explanation instead of failing with one.
ci-ok:
name: ci-ok
runs-on: ubuntu-latest
if: always()
needs:
- engine
- engine-minimums
- windows-engine
- addin-logic
- packaging
- manifests
- public-hygiene
- launcher
steps:
- name: Todos los jobs requeridos terminaron en success
shell: bash
run: |
set -euo pipefail
# A matrix job contributes ONE result: if any leg fails the whole
# job is `failure`, and if the matrix produces no legs at all it is
# `skipped`. Both are refused below, so a combination that silently
# disappears cannot pass for success.
results="${{ join(needs.*.result, ' ') }}"
echo "resultados de los jobs requeridos: $results"
bad=0
for r in $results; do
if [ "$r" != "success" ]; then
echo "::error::un job requerido terminó en «$r», no en «success»"
bad=1
fi
done
# An empty list would mean the `needs` block lost its dependencies
# in an edit, and an aggregate over nothing is vacuously true — the
# exact shape of guard that reports success while checking nothing.
if [ -z "${results// /}" ]; then
echo "::error::ci-ok no recibió ningún resultado; revisa el bloque needs"
bad=1
fi
if [ "$bad" -ne 0 ]; then exit 1; fi
echo "ci-ok: todos los jobs requeridos en success"