Skip to content

Merge pull request #15 from HorizunGroup/release/naviscoord-0.4.0 #44

Merge pull request #15 from HorizunGroup/release/naviscoord-0.4.0

Merge pull request #15 from HorizunGroup/release/naviscoord-0.4.0 #44

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.
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:
permissions:
contents: read
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
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
minimum-dependencies:
name: mínimos declarados (Python 3.10)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.10"
- name: Instalar exactamente los mínimos soportados
run: |
pip install -e server --no-deps
pip install pytest==9.0.2 "mcp==1.14.0" "reportlab==4.0.4" "pillow==10.0.0"
- 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
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==1.5.0
- 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 reconstruye el wheel sin el repositorio
run: |
mkdir /tmp/from-sdist
tar -xzf server/dist/naviscoord-*.tar.gz -C /tmp/from-sdist
python -m pip wheel --no-deps --wheel-dir /tmp/rebuilt /tmp/from-sdist/naviscoord-*
test -n "$(find /tmp/rebuilt -name 'naviscoord-*.whl' -print -quit)"
- 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: |
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: 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: Actions fijadas por SHA y sin estado efímero versionado
shell: bash
run: |
set -euo pipefail
if grep -RInE 'uses: [^#[:space:]]+@v[0-9]+' .github/workflows; then
echo "::error::una action sigue fijada solo por major tag"; exit 1
fi
if git ls-files --error-unmatch .claude/scheduled_tasks.lock >/dev/null 2>&1; then
echo "::error::scheduled_tasks.lock es estado local y no debe versionarse"; exit 1
fi
- 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
- name: Restauración del smoke en sandbox
shell: pwsh
run: ./scripts/Smoke-AddinSwap.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"]
assert "pip install" in payload["arreglo"]
print("modo degradado: ok")
PY
# ---------------------------------------------------------------- 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
- windows-engine
- minimum-dependencies
- 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"