Compare commits

..

1 Commits

Author SHA1 Message Date
6f81f572cd ci: fix anno workflows JSON parsing (node stdin argv) + harden guards
All checks were successful
SMOKE / smoke (push) Successful in 40s
CI / build-and-anchors (push) Successful in 2m13s
2026-02-28 10:33:43 +01:00
6 changed files with 103 additions and 115 deletions

View File

@@ -22,7 +22,7 @@ concurrency:
jobs:
apply-approved:
runs-on: mac-ci
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
@@ -52,15 +52,18 @@ jobs:
const cloneUrl =
repoObj?.clone_url ||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
let owner =
repoObj?.owner?.login ||
repoObj?.owner?.username ||
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
let repo =
repoObj?.name ||
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
@@ -73,15 +76,15 @@ jobs:
ev?.issue?.number ||
ev?.issue?.index ||
(process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0);
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
throw new Error("No issue number in event.json or workflow_dispatch input");
}
// label triggered (best effort; may be missing depending on Gitea payload)
const labelName =
ev?.label?.name ||
(typeof ev?.label === "string" ? ev.label : "") ||
"";
ev?.label ||
"workflow_dispatch";
const u = new URL(cloneUrl);
const origin = u.origin;
@@ -90,7 +93,7 @@ jobs:
? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
: origin;
function sh(s){ return JSON.stringify(String(s ?? "")); }
function sh(s){ return JSON.stringify(String(s)); }
process.stdout.write([
`CLONE_URL=${sh(cloneUrl)}`,
@@ -99,47 +102,26 @@ jobs:
`DEFAULT_BRANCH=${sh(defaultBranch)}`,
`ISSUE_NUMBER=${sh(issueNumber)}`,
`LABEL_NAME=${sh(labelName)}`,
`API_BASE=${sh(apiBase)}`,
// init safe defaults (avoid "unbound variable" cascades)
`SKIP=0`,
`SKIP_REASON=${sh("")}`,
`ISSUE_TYPE=${sh("")}`,
`ISSUE_TITLE=${sh("")}`,
`APPLY_RC=${sh("")}`,
`NOOP=0`,
`BRANCH=${sh("")}`,
`END_SHA=${sh("")}`
`API_BASE=${sh(apiBase)}`
].join("\n") + "\n");
NODE
echo "✅ context:"
sed -n '1,160p' /tmp/anno.env
sed -n '1,120p' /tmp/anno.env
- name: Gate fast (only if label is state/approved or workflow_dispatch)
env:
INPUT_ISSUE: ${{ inputs.issue }}
- name: Gate on label state/approved
run: |
set -euo pipefail
source /tmp/anno.env
# workflow_dispatch => allow
if [[ -n "${INPUT_ISSUE:-}" ]]; then
echo "✅ workflow_dispatch => proceed"
exit 0
fi
# if payload provides the triggering label, we can skip without API call
if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/approved" ]]; then
echo " triggering label='$LABEL_NAME' (not state/approved) => skip"
if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
echo " label=$LABEL_NAME => skip"
echo "SKIP=1" >> /tmp/anno.env
echo "SKIP_REASON=\"trigger_label_not_approved\"" >> /tmp/anno.env
exit 0
fi
echo "✅ proceed (issue=$ISSUE_NUMBER)"
echo " label unknown or approved => continue to API gating"
- name: Fetch issue + gate on state/approved + gate on Type (skip Proposer)
- name: Fetch issue + gate on Type (skip Proposer)
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
@@ -149,24 +131,24 @@ jobs:
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
curl -fsS \
ISSUE_JSON="$(curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
> /tmp/issue.json
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER")"
node --input-type=module - <<'NODE' >> /tmp/anno.env
# ✅ Robust: write JSON to file (avoid argv/stdi n "-" issue)
printf '%s' "$ISSUE_JSON" > /tmp/issue.json
node --input-type=module - /tmp/issue.json >> /tmp/anno.env <<'NODE'
import fs from "node:fs";
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
const fp = process.argv[2] || "";
const raw = fp ? fs.readFileSync(fp, "utf8") : "{}";
const issue = JSON.parse(raw || "{}");
const title = String(issue.title || "");
const body = String(issue.body || "").replace(/\r\n/g, "\n");
const labels = Array.isArray(issue.labels)
? issue.labels.map(l => String(l?.name || "")).filter(Boolean)
: [];
const hasApproved = labels.includes("state/approved");
function pickLine(key) {
const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi");
const m = body.match(re);
@@ -183,11 +165,7 @@ jobs:
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
// main gate: only act if state/approved is actually present on the issue
if (!hasApproved) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("no_state_approved")}`);
} else if (!type) {
if (!type) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
} else if (allowed.has(type)) {
@@ -203,10 +181,10 @@ jobs:
process.stdout.write(out.join("\n") + "\n");
NODE
echo "✅ issue gating:"
echo "✅ issue type gating:"
grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true
- name: Comment issue if skipped (only when state/approved was present)
- name: Comment issue if skipped (Proposer / unsupported / missing Type)
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
@@ -215,19 +193,18 @@ jobs:
source /tmp/anno.env || true
[[ "${SKIP:-0}" == "1" ]] || exit 0
[[ "${SKIP_REASON:-}" != "no_state_approved" ]] || exit 0 # do not comment on normal label churn
test -n "${FORGE_TOKEN:-}" || exit 0
[[ "${LABEL_NAME:-}" == "state/approved" || "${LABEL_NAME:-}" == "workflow_dispatch" ]] || exit 0
test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip comment"; exit 0; }
REASON="${SKIP_REASON:-}"
TYPE="${ISSUE_TYPE:-}"
if [[ "$REASON" == proposer_type:* ]]; then
MSG=" Ticket #${ISSUE_NUMBER} détecté comme **Proposer** (${TYPE}).\n\n- Ce type est **traité manuellement par les editors** (correction/fact-check + cat/*).\n- Le bot n'applique **jamais** Proposer.\n\n✅ Action : traitement éditorial manuel."
MSG=" Ticket #${ISSUE_NUMBER} détecté comme **Proposer** (${TYPE}).\n\n- Ce type est **traité manuellement par les editors** (correction/fact-check + cat/*).\n- Le bot n'applique **jamais** Proposer et n'ajoute **jamais** state/approved automatiquement.\n\n✅ Action : traitement éditorial manuel."
elif [[ "$REASON" == unsupported_type:* ]]; then
MSG=" Ticket #${ISSUE_NUMBER} ignoré : Type non supporté par le bot (${TYPE}).\n\nTypes supportés : type/media, type/reference, type/comment.\n✅ Action : traitement manuel si nécessaire."
else
MSG=" Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\n✅ Action : corriger le ticket (Type: type/media|type/reference|type/comment) ou traiter manuellement."
MSG=" Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\n✅ Action : corriger le ticket (ajouter 'Type: type/media|type/reference|type/comment') ou traiter manuellement."
fi
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
@@ -275,7 +252,7 @@ jobs:
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
npm run build
npm run build:clean
test -f dist/para-index.json || {
echo "❌ missing dist/para-index.json after build"
@@ -303,7 +280,7 @@ jobs:
START_SHA="$(git rev-parse HEAD)"
TS="$(date -u +%Y%m%d-%H%M%S)"
BR="bot/anno-${ISSUE_NUMBER}-${TS}"
echo "BRANCH=\"$BR\"" >> /tmp/anno.env
echo "BRANCH=$BR" >> /tmp/anno.env
git checkout -b "$BR"
export FORGE_API="$API_BASE"
@@ -316,13 +293,12 @@ jobs:
RC=$?
set -e
echo "APPLY_RC=\"$RC\"" >> /tmp/anno.env
echo "APPLY_RC=$RC" >> /tmp/anno.env
echo "== apply log (tail) =="
tail -n 180 "$LOG" || true
END_SHA="$(git rev-parse HEAD)"
echo "END_SHA=\"$END_SHA\"" >> /tmp/anno.env
if [[ "$RC" -ne 0 ]]; then
echo "NOOP=0" >> /tmp/anno.env
@@ -333,6 +309,7 @@ jobs:
echo "NOOP=1" >> /tmp/anno.env
else
echo "NOOP=0" >> /tmp/anno.env
echo "END_SHA=$END_SHA" >> /tmp/anno.env
fi
- name: Comment issue on failure (strict/verify/etc)
@@ -344,9 +321,13 @@ jobs:
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
RC="${APPLY_RC:-}"
[[ -n "$RC" ]] || { echo " apply not executed"; exit 0; }
[[ "$RC" != "0" ]] || { echo " no failure detected"; exit 0; }
RC="${APPLY_RC:-0}"
if [[ "$RC" == "0" ]]; then
echo " no failure detected"
exit 0
fi
test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip comment"; exit 0; }
if [[ -f /tmp/apply.log ]]; then
BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')"
@@ -372,10 +353,11 @@ jobs:
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
RC="${APPLY_RC:-}"
[[ "$RC" == "0" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "1" ]] || exit 0
test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip comment"; exit 0; }
MSG=" Ticket #${ISSUE_NUMBER} : rien à appliquer (déjà présent / dédupliqué)."
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
@@ -394,9 +376,12 @@ jobs:
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-}" == "0" ]] || { echo " apply not ok -> skip push"; exit 0; }
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; }
[[ -n "${BRANCH:-}" ]] || { echo " missing BRANCH -> skip push"; exit 0; }
test -d .git || { echo " no git repo -> skip push"; exit 0; }
test -n "${BRANCH:-}" || { echo " missing BRANCH -> skip push"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip push"; exit 0; }
AUTH_URL="$(node --input-type=module -e '
const [clone, tok] = process.argv.slice(1);
@@ -418,10 +403,12 @@ jobs:
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-}" == "0" ]] || { echo " apply not ok -> skip PR"; exit 0; }
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip PR"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip PR"; exit 0; }
[[ -n "${BRANCH:-}" ]] || { echo " missing BRANCH -> skip PR"; exit 0; }
[[ -n "${END_SHA:-}" ]] || { echo " missing END_SHA -> skip PR"; exit 0; }
test -n "${BRANCH:-}" || { echo " missing BRANCH -> skip PR"; exit 0; }
test -n "${END_SHA:-}" || { echo " missing END_SHA -> skip PR"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip PR"; exit 0; }
PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}"
PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK."
@@ -463,9 +450,7 @@ jobs:
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
RC="${APPLY_RC:-}"
[[ -n "$RC" ]] || { echo "❌ apply did not run"; exit 2; }
RC="${APPLY_RC:-0}"
if [[ "$RC" != "0" ]]; then
echo "❌ apply failed (rc=$RC)"
exit "$RC"

View File

@@ -22,7 +22,7 @@ concurrency:
jobs:
reject:
runs-on: mac-ci
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
@@ -77,8 +77,8 @@ jobs:
const labelName =
ev?.label?.name ||
(typeof ev?.label === "string" ? ev.label : "") ||
"";
ev?.label ||
"workflow_dispatch";
let apiBase = "";
if (process.env.FORGE_API && String(process.env.FORGE_API).trim()) {
@@ -89,42 +89,33 @@ jobs:
apiBase = "";
}
function sh(s){ return JSON.stringify(String(s ?? "")); }
function sh(s){ return JSON.stringify(String(s)); }
process.stdout.write([
`OWNER=${sh(owner)}`,
`REPO=${sh(repo)}`,
`ISSUE_NUMBER=${sh(issueNumber)}`,
`LABEL_NAME=${sh(labelName)}`,
`API_BASE=${sh(apiBase)}`,
`SKIP=0`
`API_BASE=${sh(apiBase)}`
].join("\n") + "\n");
NODE
echo "✅ context:"
sed -n '1,160p' /tmp/reject.env
sed -n '1,120p' /tmp/reject.env
- name: Gate fast (only if label is state/rejected or workflow_dispatch)
env:
INPUT_ISSUE: ${{ inputs.issue }}
- name: Gate on label state/rejected only
run: |
set -euo pipefail
source /tmp/reject.env
if [[ -n "${INPUT_ISSUE:-}" ]]; then
echo "✅ workflow_dispatch => proceed"
exit 0
fi
if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/rejected" ]]; then
echo " triggering label='$LABEL_NAME' (not state/rejected) => skip"
if [[ "$LABEL_NAME" != "state/rejected" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
echo " label=$LABEL_NAME => skip"
echo "SKIP=1" >> /tmp/reject.env
exit 0
fi
echo "✅ proceed (issue=$ISSUE_NUMBER)"
echo " label unknown or rejected => continue to API gating"
- name: Comment + close (only if issue has state/rejected; conflict-guard approved+rejected)
- name: Comment + close (only if not conflicting with state/approved)
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
@@ -135,29 +126,33 @@ jobs:
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
test -n "${API_BASE:-}" || { echo "❌ Missing API_BASE"; exit 1; }
curl -fsS \
ISSUE_JSON="$(curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
> /tmp/issue.json
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER")"
node --input-type=module - <<'NODE' > /tmp/reject.flags
# ✅ Robust: write JSON to file (avoid argv/stdi n "-" issue)
printf '%s' "$ISSUE_JSON" > /tmp/issue.json
node --input-type=module - /tmp/issue.json > /tmp/reject.flags <<'NODE'
import fs from "node:fs";
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : [];
const fp = process.argv[2] || "";
const raw = fp ? fs.readFileSync(fp, "utf8") : "{}";
const issue = JSON.parse(raw || "{}");
const labels = Array.isArray(issue.labels)
? issue.labels.map(l => String(l.name || "")).filter(Boolean)
: [];
const hasApproved = labels.includes("state/approved");
const hasRejected = labels.includes("state/rejected");
process.stdout.write(`HAS_APPROVED=${hasApproved ? "1":"0"}\nHAS_REJECTED=${hasRejected ? "1":"0"}\n`);
NODE
source /tmp/reject.flags
# If issue does not actually have state/rejected -> do nothing (normal label churn)
if [[ "${HAS_REJECTED:-0}" != "1" ]]; then
echo " issue has no state/rejected => skip"
exit 0
fi
if [[ "${HAS_APPROVED:-0}" == "1" && "${HAS_REJECTED:-0}" == "1" ]]; then
MSG="⚠️ Conflit d'état sur le ticket #${ISSUE_NUMBER} : labels **state/approved** et **state/rejected** présents.\n\n➡ Action manuelle requise : retirer l'un des deux labels avant relance."
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"

View File

@@ -6,7 +6,7 @@ on:
jobs:
label:
runs-on: mac-ci
runs-on: ubuntu-latest
steps:
- name: Apply labels from Type/State/Category
env:

View File

@@ -3,7 +3,7 @@ name: CI
on:
push:
pull_request:
branches: [main]
branches: [master]
workflow_dispatch:
env:
@@ -15,7 +15,7 @@ defaults:
jobs:
build-and-anchors:
runs-on: mac-ci
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm

View File

@@ -26,9 +26,9 @@ concurrency:
jobs:
deploy:
runs-on: nas-deploy
runs-on: ubuntu-latest
container:
image: localhost:5000/archicratie/nas-deploy-node22@sha256:fefa8bb307005cebec07796661ab25528dc319c33a8f1e480e1d66f90cd5cff6
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
steps:
- name: Tools sanity
@@ -127,17 +127,25 @@ jobs:
echo " no annotations/media change -> skip deploy"
fi
- name: Toolchain sanity + resolve COMPOSE_PROJECT_NAME
- name: Install docker client + docker compose plugin (v2) + python yaml
run: |
set -euo pipefail
source /tmp/deploy.env
[[ "${GO:-0}" == "1" ]] || { echo " skipped"; exit 0; }
# tools are prebaked in the image
git --version
apt-get -o Acquire::Retries=5 -o Acquire::ForceIPv4=true update
apt-get install -y --no-install-recommends ca-certificates curl docker.io python3 python3-yaml
rm -rf /var/lib/apt/lists/*
mkdir -p /usr/local/lib/docker/cli-plugins
curl -fsSL \
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64" \
-o /usr/local/lib/docker/cli-plugins/docker-compose
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
docker version
docker compose version
python3 -c 'import yaml; print("PyYAML OK")'
python3 --version
# Reuse existing compose project name if containers already exist
PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"

View File

@@ -3,7 +3,7 @@ on: [push, workflow_dispatch]
jobs:
smoke:
runs-on: mac-ci
runs-on: ubuntu-latest
steps:
- run: node -v && npm -v
- run: echo "runner OK"