# .gitlab-ci.yml — Unified SpecPress CI pipeline. # # Jobs: # # finalize-cr — Runs first, on pushes to "main" or "Rel-*" branches only # (enforced via rules:). Renames CRxxxx.json to CR####.json # (zero-padded CR number) and pushes the commit back to the # branch. Requires CI_PUSH_TOKEN to be set in CI/CD variables. # # export-document — Runs on every push to any branch, after finalize-cr. # On main/Rel-* branches this means it sees the renamed # CR####.json (no CRxxxx.json), so it produces a normal DOCX # and HTML export instead of a DIFF. # On feature branches it generates a DOCX DIFF and HTML DIFF # if CRxxxx.json exists, otherwise a normal DOCX and HTML export. # Artifacts (DOCX + HTML + media) are retained for 30 days. # # validate-cr — Runs on merge requests targeting "main" or "Rel-*" branches. # Blocks the merge if CR metadata is missing, invalid, or # conflicts with an existing CR number. # # release — Runs when a version tag (e.g. 20.3.0) is pushed. Creates a # GitLab Release with direct download links for the HTML and # DOCX artifacts on the Releases page. # # Copy this file to the top level of your specification repository and rename # it to ".gitlab-ci.yml". Configure the variables below as needed. # Run pipelines on push events and on merge request events (for validate-cr). # Using workflow:rules ensures both pipeline types are created independently, # so a push to a branch with an open MR triggers both a branch pipeline # (for export) and an MR pipeline (for validate-cr) rather than only one. workflow: rules: - if: $CI_PIPELINE_SOURCE == "push" - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_TAG variables: # path to the specification root folder, relative to repo root: SPEC_ROOT: "specification" # specification number (e.g., "38.413"): SPEC_NUMBER: "TR 38.xxx" # path to front page meta data in JSON format (optional): FRONT_PAGE_DATA: "specification/assets/specFrontPage.json" # username for CI push (finalize-cr job). Default value should be OK! CI_PUSH_USER: "oauth2" # CI_PUSH_TOKEN is required for finalize-cr. # To generate: Settings > Access Tokens > Add new token, # select scope "write_repository", copy the value. # Then store it via Settings > CI/CD > Variables > CI/CD Variables > Add variable # paste the key into the value field # set Key=CI_PUSH_TOKEN, and # enable "Masked and hidden" and "Protected". # Repository and branch from which the CI pipeline shall download the # specpress library for rendering HTML- and DOCX versions upon commits. # Default values should be OK! SPECPRESS_REPO: "https://github.com/Ericsson/specpress.git" SPECPRESS_REF: "main" # Docker image for the export-document job. The default node:20 image does not # include Msc-generator. To enable MSC-Gen diagram rendering in CI, build a # custom image that includes msc-gen and set this variable to its registry path. # See ci_templates/Dockerfile.specpress for an example. DOCX_IMAGE: "node:20" stages: - finalize - build - validate - deploy # ----------------------------------------------------------------------------- # finalize-cr: rename CRxxxx.json → CR####.json after merge # # Runs only on pushes to main or Rel-* branches (enforced via rules:). # Must run before export-docx so the DOCX export sees CR####.json, not # CRxxxx.json, and produces a normal export rather than a DOCX DIFF. # ----------------------------------------------------------------------------- finalize-cr: stage: finalize image: alpine:latest before_script: - | if [ -z "$CI_PUSH_TOKEN" ]; then echo "ERROR: CI_PUSH_TOKEN is not set. Set it in Settings > CI/CD > Variables." exit 1 fi if [ -z "$CI_PUSH_USER" ]; then echo "ERROR: CI_PUSH_USER is not set. Set it in Settings > CI/CD > Variables." exit 1 fi - apk add --no-cache git jq script: - | CR_FILE="${SPEC_ROOT}/history/CRxxxx.json" if [ ! -f "$CR_FILE" ]; then echo "No $CR_FILE found — nothing to finalize." exit 0 fi CR_NUMBER=$(jq -r '.CR // empty' "$CR_FILE") if [ -z "$CR_NUMBER" ]; then echo "ERROR: $CR_FILE has no CR field — cannot rename." exit 1 fi CR_PADDED=$(printf "CR%04d" "$CR_NUMBER") TARGET_FILE="${SPEC_ROOT}/history/${CR_PADDED}.json" if [ -f "$TARGET_FILE" ]; then echo "ERROR: $TARGET_FILE already exists — CR number conflict." exit 1 fi echo "Renaming $CR_FILE → $TARGET_FILE" git mv "$CR_FILE" "$TARGET_FILE" git config user.email "ci@specpress" git config user.name "SpecPress CI" git commit -m "Finalize ${CR_PADDED}: rename CRxxxx.json → ${CR_PADDED}.json" REMOTE_URL=$(echo "$CI_PROJECT_URL.git" | sed "s|https://|https://${CI_PUSH_USER}:${CI_PUSH_TOKEN}@|") git push "$REMOTE_URL" HEAD:"$CI_COMMIT_REF_NAME" echo "✓ ${CR_PADDED}.json committed and pushed." rules: - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_REF_NAME == "main" - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_REF_NAME =~ /^Rel-\d+$/ # ----------------------------------------------------------------------------- # export: build DOCX and HTML on every push # # On CR branches (CRxxxx.json exists): generates a DOCX DIFF and an HTML DIFF # against the merge-base of the target release branch. # On regular branches: generates a plain DOCX and plain HTML export. # # Artifacts include both output/*.docx and output/*.html + output/media/. # The HTML artifact is exposed as a browsable link on the MR/pipeline page # via "expose_as". # ----------------------------------------------------------------------------- export: stage: build image: $DOCX_IMAGE before_script: - apt-get update && apt-get install -y --no-install-recommends git libreoffice-writer python3-uno chromium msc-generator jq - export CHROME_BIN=$(which chromium) - export MSCGEN_BIN=$(which msc-gen 2>/dev/null || echo "") script: - git clone --depth 1 --branch $SPECPRESS_REF $SPECPRESS_REPO /tmp/specpress - cd /tmp/specpress && npm ci --omit=dev && cd - - mkdir -p output - COMMIT_DATE=$(git show -s --format=%ci HEAD | awk '{print $1"_"$2}' | tr ':' '-') - SHORT_SHA=$(git rev-parse --short HEAD) - BRANCH_NAME=$(echo $CI_COMMIT_REF_NAME | tr '/' '_') - | CR_FILE="${SPEC_ROOT}/history/CRxxxx.json" if [ -f "$CR_FILE" ]; then echo "=== CR branch detected (${CR_FILE} exists) ===" RELEASE=$(jq -r '.Release // empty' "$CR_FILE" 2>/dev/null) if [ -n "$RELEASE" ]; then TARGET_BRANCH="Rel-${RELEASE}" if git ls-remote --exit-code --heads origin "$TARGET_BRANCH" >/dev/null 2>&1; then echo "Using release branch: ${TARGET_BRANCH}" else echo "Branch ${TARGET_BRANCH} not found, falling back to main" TARGET_BRANCH="main" fi else echo "No Release field in CRxxxx.json, using main" TARGET_BRANCH="main" fi git fetch origin "$TARGET_BRANCH" --unshallow 2>/dev/null || git fetch origin "$TARGET_BRANCH" BASE_COMMIT=$(git merge-base HEAD "origin/$TARGET_BRANCH") echo "Target branch: ${TARGET_BRANCH}, Merge-base: ${BASE_COMMIT}" DOCX_LABEL="${COMMIT_DATE}_${SPEC_NUMBER}_DIFF_${BRANCH_NAME}_${SHORT_SHA}.docx" HTML_LABEL="${COMMIT_DATE}_${SPEC_NUMBER}_DIFF_${BRANCH_NAME}_${SHORT_SHA}.html" echo "--- Generating DOCX DIFF ---" node /tmp/specpress/lib/cli/export-docx-diff.js \ "$SPEC_ROOT" \ --output "output/export.docx" \ --base "$BASE_COMMIT" \ --revisions HEAD \ --backend libreoffice \ --spec-root "$SPEC_ROOT" \ --cr-cover-page-data "$CR_FILE" echo "--- Generating HTML DIFF ---" node /tmp/specpress/lib/cli/export-html-diff.js \ "$SPEC_ROOT" \ --output "output/export.html" \ --base "$BASE_COMMIT" \ --revision HEAD \ --spec-root "$SPEC_ROOT" \ --cr-cover-page-data "$CR_FILE" else echo "=== No CRxxxx.json found — normal export ===" DOCX_LABEL="${COMMIT_DATE}_${SPEC_NUMBER}_${BRANCH_NAME}_${SHORT_SHA}.docx" HTML_LABEL="${COMMIT_DATE}_${SPEC_NUMBER}_${BRANCH_NAME}_${SHORT_SHA}.html" echo "--- Generating DOCX ---" node /tmp/specpress/lib/cli/export-docx.js \ "$SPEC_ROOT" \ "output/export.docx" \ --spec-root "$SPEC_ROOT" \ ${FRONT_PAGE_DATA:+--front-page-data "$FRONT_PAGE_DATA"} echo "--- Generating HTML ---" node /tmp/specpress/lib/cli/export-html.js \ "$SPEC_ROOT" \ "output/export.html" \ --spec-root "$SPEC_ROOT" \ ${FRONT_PAGE_DATA:+--front-page-data "$FRONT_PAGE_DATA"} fi echo "DOCX: output/export.docx ($DOCX_LABEL)" echo "HTML: output/export.html ($HTML_LABEL)" artifacts: expose_as: 'Rendered HTML' paths: - output/export.docx - output/export.html expire_in: 30 days rules: - if: $CI_PIPELINE_SOURCE == "push" # ----------------------------------------------------------------------------- # release: create a GitLab Release with direct HTML and DOCX links # # Runs only when a version tag (e.g. 20.3.0) is pushed to main or a Rel-* # branch. Attaches the exported HTML and DOCX as named asset links on the # GitLab Releases page, accessible without downloading a zip. # ----------------------------------------------------------------------------- release: stage: deploy image: registry.gitlab.com/gitlab-org/release-cli:latest needs: [export] script: - echo "Creating release $CI_COMMIT_TAG" release: tag_name: $CI_COMMIT_TAG description: "Exported specification $CI_COMMIT_TAG" assets: links: - name: "$SPEC_NUMBER $CI_COMMIT_TAG (HTML)" url: "$CI_PROJECT_URL/-/jobs/artifacts/$CI_COMMIT_TAG/raw/output/export.html?job=export" - name: "$SPEC_NUMBER $CI_COMMIT_TAG (DOCX)" url: "$CI_PROJECT_URL/-/jobs/artifacts/$CI_COMMIT_TAG/raw/output/export.docx?job=export" rules: - if: $CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+$/ # ----------------------------------------------------------------------------- # validate-cr: block MRs with invalid CR metadata # # NOTE: For this job to block merging, enable "Pipelines must succeed" in # Settings > Merge requests > Merge checks in your GitLab project. # # This job exits with code 1 (pipeline failure) for all validation errors, # including work-in-progress states such as a missing CR number. This is # intentional: the MR should not be mergeable until all checks pass. # # Validation results are also written as a JUnit XML report (cr-validation.xml). # GitLab renders this in the MR "Tests" tab, showing each check by name and # surfacing failure messages directly on the MR page — no log diving needed. # # If you do not want to receive GitLab failure emails for these expected # failures, adjust your notification preferences: # Profile > Notifications > [project] > Custom > uncheck "Failed pipeline" # ----------------------------------------------------------------------------- validate-cr: stage: validate image: node:20 before_script: - apt-get update && apt-get install -y --no-install-recommends git jq - git clone --depth 1 --branch $SPECPRESS_REF $SPECPRESS_REPO /tmp/specpress - cd /tmp/specpress && npm ci --omit=dev && cd - script: - | CR_FILE="${SPEC_ROOT}/history/CRxxxx.json" HISTORY_DIR="${SPEC_ROOT}/history" TARGET="$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" JUNIT="cr-validation.xml" FAILED=0 # Helpers to accumulate JUnit test cases JUNIT_CASES="" pass() { JUNIT_CASES="${JUNIT_CASES}"; } fail() { MSG=$(echo "$2" | sed 's/&/\&/g; s//\>/g; s/"/\"/g') JUNIT_CASES="${JUNIT_CASES}${MSG}" FAILED=1 } write_junit() { echo "" > "$JUNIT" echo "" >> "$JUNIT" echo "$JUNIT_CASES" >> "$JUNIT" echo "" >> "$JUNIT" } # --- (a) CRxxxx.json must exist --- if [ ! -f "$CR_FILE" ]; then fail "CRxxxx.json exists" "$CR_FILE not found. A CR metadata file is required before merging." write_junit echo "ERROR: $CR_FILE not found. A CR metadata file is required before merging." exit 1 fi pass "CRxxxx.json exists" echo "✓ $CR_FILE exists" # --- (b) CRxxxx.json must match the schema --- VALIDATION=$(node -e " const { loadCRCoverPageData } = require('/tmp/specpress/lib/common/crCoverPageLoader'); const path = require('path'); const result = loadCRCoverPageData(path.resolve('$CR_FILE')); if (!result.valid) { console.log('ERRORS:' + result.errors.join('; ')); process.exit(1); } " 2>&1) || { SCHEMA_ERRORS=$(echo "$VALIDATION" | grep "^ERRORS:" | sed 's/^ERRORS://') fail "Schema validation" "$CR_FILE does not match the schema:${SCHEMA_ERRORS}" write_junit echo "ERROR: $CR_FILE does not match the schema." echo "$SCHEMA_ERRORS" | sed 's/^/ /' exit 1 } pass "Schema validation" echo "✓ Schema validation passed" # --- (c) CR field must be present --- CR_NUMBER=$(jq -r '.CR // empty' "$CR_FILE") if [ -z "$CR_NUMBER" ]; then fail "CR number present" "$CR_FILE has no \"CR\" field. A CR number is required before merging." write_junit echo "ERROR: $CR_FILE has no \"CR\" field. A CR number is required before merging." exit 1 fi pass "CR number present" echo "✓ CR field present: $CR_NUMBER" # --- (d) Release must match target branch (unless target is main) --- if [ "$TARGET" != "main" ]; then RELEASE=$(jq -r '.Release // empty' "$CR_FILE") EXPECTED_BRANCH="Rel-${RELEASE}" if [ "$EXPECTED_BRANCH" != "$TARGET" ]; then fail "Release matches target branch" "Release field (Rel-${RELEASE}) does not match target branch (${TARGET})." write_junit echo "ERROR: Release field (Rel-${RELEASE}) does not match target branch (${TARGET})." exit 1 fi pass "Release matches target branch" echo "✓ Release matches target branch: $TARGET" else pass "Release matches target branch" echo "✓ Target is main — skipping Release check" fi # --- (e) CR####.json must not already exist --- CR_PADDED=$(printf "CR%04d" "$CR_NUMBER") FINAL_FILE="${HISTORY_DIR}/${CR_PADDED}.json" if [ -f "$FINAL_FILE" ]; then fail "CR number not already used" "$FINAL_FILE already exists on the source branch. CR number $CR_NUMBER is already used." write_junit echo "ERROR: $FINAL_FILE already exists on the source branch. CR number $CR_NUMBER is already used." exit 1 fi if git cat-file -e "origin/${TARGET}:${FINAL_FILE}" 2>/dev/null; then fail "CR number not already used" "$FINAL_FILE already exists on the target branch (${TARGET}). CR number $CR_NUMBER is already used." write_junit echo "ERROR: $FINAL_FILE already exists on the target branch (${TARGET}). CR number $CR_NUMBER is already used." exit 1 fi pass "CR number not already used" echo "✓ ${CR_PADDED}.json does not exist on source or target branch" write_junit echo "" echo "=== All CR validation checks passed ===" artifacts: when: always reports: junit: cr-validation.xml rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^Rel-\d+$/