From d74dfb99387e7bb371176dd3c5b27e500bcb0ba9 Mon Sep 17 00:00:00 2001
From: Nessar
Date: Tue, 13 Oct 2026 19:41:10 +0200
Subject: [PATCH] ci: migrate to Gitea Actions
---
.agents/skills/wizard/SKILL.md | 2 +-
.antigravityrules | 5 +-
.../default_merge_message/MERGE_TEMPLATE.md | 5 +
.gitea/workflows/ci.yml | 210 ++++++++++++++++++
.gitlab-ci.yml | 179 ---------------
AGENTS.md | 2 +-
README.md | 33 +--
docs/benchmark/README.md | 10 +-
docs/gitea-actions.md | 131 +++++++++++
scripts/ci-context.mjs | 35 +++
scripts/ci-image.mjs | 40 ++++
scripts/ci-image.test.mjs | 137 ++++++++++++
scripts/ci-policy.mjs | 32 +--
scripts/ci-policy.test.mjs | 103 +++++----
14 files changed, 662 insertions(+), 262 deletions(-)
create mode 100644 .gitea/default_merge_message/MERGE_TEMPLATE.md
create mode 100644 .gitea/workflows/ci.yml
delete mode 100644 .gitlab-ci.yml
create mode 100644 docs/gitea-actions.md
create mode 100644 scripts/ci-context.mjs
create mode 100644 scripts/ci-image.mjs
create mode 100644 scripts/ci-image.test.mjs
diff --git a/.agents/skills/wizard/SKILL.md b/.agents/skills/wizard/SKILL.md
index ca9e297..e5119ff 100644
--- a/.agents/skills/wizard/SKILL.md
+++ b/.agents/skills/wizard/SKILL.md
@@ -19,7 +19,7 @@ A wizard is ephemeral by default — built for one run, saved to a scratch or `s
Work out every manual step the human must take and every value that gets captured along the way. Read the repo first — don't ask cold:
-- For setup: configuration examples, README, compose/framework configuration, and the actual CI files. This repository is hosted on Gitea; read the README's migration status, `.gitea/workflows/` when present, and the preserved `.gitlab-ci.yml` while conversion is pending. Inspect secret names and references without displaying existing secret values. Include only values needed for the requested procedure, not every variable in the repository.
+- For setup: configuration examples, README, compose/framework configuration, and the actual CI files. This repository is hosted on Gitea; read `.gitea/workflows/` and `docs/gitea-actions.md` for the configured workflow and owner-activation requirements. Inspect secret names and references without displaying existing secret values. Include only values needed for the requested procedure, not every variable in the repository.
- For a migration or transition: the current state, the target state, and the irreversible actions between them.
Then show the user the ordered list of stages and the values each produces, and confirm — they may add, drop, or reorder.
diff --git a/.antigravityrules b/.antigravityrules
index 501a40a..0ba839f 100644
--- a/.antigravityrules
+++ b/.antigravityrules
@@ -26,10 +26,11 @@ All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to
```
4. **CI Checks**:
- - You must execute the local validation commands before completing your task. CI conversion to Gitea Actions is tracked in issue #268; the preserved `.gitlab-ci.yml` is a migration reference, and local checks do not satisfy Gitea's migration gate. All of the following commands must pass:
+ - You must execute the local validation commands before completing your task. The Gitea workflow is `.gitea/workflows/ci.yml`; owner activation is tracked in issue #268, and local checks do not satisfy the migration gate. All of the following commands must pass:
- **Linting check**: `bun run lint` (or use `bun run lint:fix` to resolve auto-fixable issues)
- **Formatting check**: `bun run format:check`
- **TypeScript Type check**: `bunx tsc --noEmit -p tsconfig.app.json` and `bunx tsc --noEmit -p tsconfig.spec.json`
- - **Unit Tests and Coverage**: generate the language registry, then run the coverage command in rule 1, matching the preserved functional-test job.
+ - **Unit Tests and Coverage**: generate the language registry, then run the coverage command in rule 1, matching the Gitea functional-test job.
+ - **CI policy and publication**: `node scripts/ci-policy.test.mjs` and `node scripts/ci-image.test.mjs`
- **Production Build**: `bun run build -- --configuration production`
- The separate performance benchmark remains advisory while issue #78 is open. Run it outside coverage with `NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1` when verifying performance; see `docs/benchmark/README.md`.
diff --git a/.gitea/default_merge_message/MERGE_TEMPLATE.md b/.gitea/default_merge_message/MERGE_TEMPLATE.md
new file mode 100644
index 0000000..57cbefc
--- /dev/null
+++ b/.gitea/default_merge_message/MERGE_TEMPLATE.md
@@ -0,0 +1,5 @@
+${PullRequestTitle}
+
+${ClosingIssues}
+
+See pull request ${PullRequestReference}
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..09791f1
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,210 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+ types: [opened, reopened, synchronize, edited]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.event_name }}-${{ github.ref }}
+ cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
+
+jobs:
+ policy:
+ name: policy
+ runs-on: ubuntu-latest
+ container: node:24.21.0
+ outputs:
+ slug: ${{ steps.policy.outputs.slug }}
+ tag: ${{ steps.policy.outputs.tag }}
+ steps:
+ - uses: https://gitea.com/actions/checkout@v4
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Policy regression tests
+ run: node scripts/ci-policy.test.mjs && node scripts/ci-image.test.mjs
+ - name: Validate event and commit
+ id: policy
+ run: node scripts/ci-policy.mjs
+
+ validate:
+ name: validate
+ runs-on: ubuntu-latest
+ container: node:24.21.0
+ steps:
+ - uses: https://gitea.com/actions/checkout@v4
+ with:
+ persist-credentials: false
+ - run: npm install --global bun@1.4.2
+ - run: bun install --frozen-lockfile
+ - run: bun run lint
+ - run: bun run format:check
+ - run: bunx prettier --check 'scripts/ci-*.mjs' '.gitea/workflows/*.yml'
+ - run: bunx tsc --noEmit -p tsconfig.app.json
+ - run: bunx tsc --noEmit -p tsconfig.spec.json
+
+ test:
+ name: test
+ runs-on: ubuntu-latest
+ container: node:24.21.0
+ env:
+ NODE_OPTIONS: --max-old-space-size=2048
+ steps:
+ - uses: https://gitea.com/actions/checkout@v4
+ with:
+ persist-credentials: false
+ - run: npm install --global bun@1.4.2
+ - run: bun install --frozen-lockfile
+ - run: bun scripts/generate-langs.js
+ - name: Functional coverage
+ run: bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
+ - name: Coverage report
+ if: ${{ always() }}
+ uses: https://gitea.com/actions/upload-artifact@v3
+ with:
+ name: coverage
+ path: coverage/
+ retention-days: 7
+ if-no-files-found: error
+
+ benchmark:
+ name: benchmark
+ runs-on: ubuntu-latest
+ container: node:24.21.0
+ # shortcut: advisory while #78 is open; restore the gate after the protocol passes.
+ continue-on-error: true
+ env:
+ NODE_OPTIONS: --max-old-space-size=2048
+ steps:
+ - uses: https://gitea.com/actions/checkout@v4
+ with:
+ persist-credentials: false
+ - run: npm install --global bun@1.4.2
+ - run: bun install --frozen-lockfile
+ - run: bun scripts/generate-langs.js
+ - name: Unchanged benchmark protocol
+ run: timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
+
+ build:
+ name: build
+ runs-on: ubuntu-latest
+ container: node:24.21.0
+ steps:
+ - uses: https://gitea.com/actions/checkout@v4
+ with:
+ persist-credentials: false
+ - run: npm install --global bun@1.4.2
+ - run: bun install --frozen-lockfile
+ - run: bun run build -- --configuration production
+ - uses: https://gitea.com/actions/upload-artifact@v3
+ with:
+ name: dist
+ path: dist/
+ retention-days: 7
+ if-no-files-found: error
+
+ security:
+ name: security
+ runs-on: ubuntu-latest
+ container: node:24.21.0
+ steps:
+ - uses: https://gitea.com/actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Install scanners
+ run: |
+ apt-get update
+ apt-get install --yes --no-install-recommends python3-venv
+ python3 -m venv /tmp/semgrep
+ /tmp/semgrep/bin/pip install semgrep==1.180.0
+ case "$(uname -m)" in
+ x86_64) trivy_arch=64bit ;;
+ aarch64) trivy_arch=ARM64 ;;
+ *) echo 'Unsupported Trivy architecture' >&2; exit 1 ;;
+ esac
+ archive="trivy_0.75.0_Linux-${trivy_arch}.tar.gz"
+ curl --fail --location --silent --show-error "https://github.com/aquasecurity/trivy/releases/download/v0.75.0/${archive}" --output "/tmp/${archive}"
+ curl --fail --location --silent --show-error https://github.com/aquasecurity/trivy/releases/download/v0.75.0/trivy_0.75.0_checksums.txt --output /tmp/trivy-checksums.txt
+ (cd /tmp && sha256sum --check --ignore-missing trivy-checksums.txt)
+ tar -xzf "/tmp/${archive}" -C /usr/local/bin trivy
+ - name: JavaScript and TypeScript SAST
+ run: /tmp/semgrep/bin/semgrep scan --config p/javascript --config p/typescript --metrics off --sarif --output semgrep-results.sarif src scripts
+ - name: Dependency and secret scan
+ if: ${{ !cancelled() }}
+ run: trivy fs --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
+ - name: Security reports
+ if: ${{ always() }}
+ uses: https://gitea.com/actions/upload-artifact@v3
+ with:
+ name: security
+ path: '*-results.sarif'
+ retention-days: 7
+ if-no-files-found: error
+
+ images:
+ name: images
+ needs: [policy, validate, test, build, security]
+ if: >-
+ success() &&
+ (github.event_name == 'push' ||
+ (github.event_name == 'pull_request' &&
+ github.event.pull_request.head.repo.full_name == github.repository))
+ runs-on: ubuntu-latest
+ container: docker:29.8.2
+ concurrency:
+ group: image-${{ needs.policy.outputs.tag != '' && 'stable' || needs.policy.outputs.slug }}
+ cancel-in-progress: false
+ services:
+ docker:
+ image: docker:29.8.2-dind
+ env:
+ DOCKER_TLS_CERTDIR: ''
+ options: >-
+ --privileged
+ --health-cmd "docker info"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 12
+ env:
+ DOCKER_HOST: tcp://docker:2375
+ DOCKER_TLS_CERTDIR: ''
+ defaults:
+ run:
+ shell: sh
+ steps:
+ - name: Install checkout and policy runtimes
+ run: apk add --no-cache nodejs git
+ - uses: https://gitea.com/actions/checkout@v4
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Build branch images or promote a release
+ env:
+ REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
+ REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
+ run: node scripts/ci-image.mjs
+
+ required:
+ name: required
+ if: ${{ always() }}
+ needs: [policy, validate, test, build, security, images]
+ runs-on: ubuntu-latest
+ container: node:24.21.0
+ env:
+ CHECK_RESULTS: ${{ needs.policy.result }} ${{ needs.validate.result }} ${{ needs.test.result }} ${{ needs.build.result }} ${{ needs.security.result }}
+ IMAGE_RESULT: ${{ needs.images.result }}
+ IMAGE_EXPECTED: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}
+ steps:
+ - name: Require successful validation and expected publication
+ run: |
+ test "$CHECK_RESULTS" = 'success success success success success'
+ if [ "$IMAGE_EXPECTED" = 'true' ]; then
+ test "$IMAGE_RESULT" = 'success'
+ else
+ test "$IMAGE_RESULT" = 'skipped'
+ fi
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
deleted file mode 100644
index b0ee154..0000000
--- a/.gitlab-ci.yml
+++ /dev/null
@@ -1,179 +0,0 @@
-image: oven/bun:latest
-
-workflow:
- auto_cancel:
- on_new_commit: interruptible
- rules:
- - if: $CI_PIPELINE_SOURCE == "merge_request_event"
- - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"
- when: never
- - if: $CI_PIPELINE_SOURCE == "push"
-
-default:
- interruptible: true
-
-stages:
- - validate
- - test
- - build
- - security
- - docker
-
-variables:
- DOCKER_DRIVER: overlay2
- DOCKER_TLS_CERTDIR: ''
- AST_ENABLE_MR_PIPELINES: 'true'
-
-cache:
- key:
- files:
- - bun.lock
- paths:
- - .bun/
-
-.node_setup:
- rules:
- - when: on_success
- before_script:
- - bun install --frozen-lockfile
-
-# -- Validate Stage --
-
-ci-policy:
- image: node:24.21.0
- stage: validate
- variables:
- GIT_DEPTH: '0'
- rules:
- - when: on_success
- script:
- - node scripts/ci-policy.test.mjs
- - node scripts/ci-policy.mjs
-
-lint:
- extends: .node_setup
- stage: validate
- script:
- - bunx eslint "src/**/*.ts"
-
-prettier:
- extends: .node_setup
- stage: validate
- script:
- - bunx prettier --check "src/**/*.{ts,html,scss,css,json}"
-
-type-check:
- extends: .node_setup
- stage: validate
- script:
- - bunx tsc --noEmit
-
-# -- Test Stage --
-
-test:
- image: node:24.21.0
- extends: .node_setup
- stage: test
- variables:
- NODE_OPTIONS: --max-old-space-size=2048
- before_script:
- - npm install --global bun@1.4.2
- - bun install --frozen-lockfile
- script:
- - |
- node -e 'console.log("CI memory (bytes)", { available: process.availableMemory(), heapLimit: require("node:v8").getHeapStatistics().heap_size_limit })'
- - bun scripts/generate-langs.js
- # ponytail: one worker bounds peak RAM; increase concurrency after adding runner memory.
- - bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
- artifacts:
- when: always
- paths:
- - coverage/
- reports:
- coverage_report:
- coverage_format: cobertura
- path: coverage/cobertura-coverage.xml
- expire_in: 7 days
-
-# shortcut: benchmark failures are advisory while #78 is open; restore the gate when #78 passes.
-benchmark:
- extends: test
- allow_failure: true
- script:
- - bun scripts/generate-langs.js
- - bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
- artifacts: null
-
-# -- Build Stage --
-
-build:
- image: node:24.21.0
- extends: .node_setup
- stage: build
- before_script:
- - npm install --global bun@1.4.2
- - bun install --frozen-lockfile
- script:
- - bun run build -- --configuration production
- artifacts:
- paths:
- - dist/
- expire_in: 7 days
-
-# -- Security Stage --
-
-include:
- - template: Security/SAST.gitlab-ci.yml
-
-trivy_scan:
- stage: security
- image:
- name: aquasec/trivy:latest
- entrypoint: ['']
- before_script: [] # override global before_script
- rules:
- - when: on_success
- script:
- # Trivy will scan the project directory
- - trivy fs --format sarif --output trivy-results.sarif .
- artifacts:
- reports:
- sast: trivy-results.sarif
- expire_in: 7 days
-
-# -- Docker Stage --
-
-.docker:
- stage: docker
- image: docker:29.8.2
- services:
- - docker:29.8.2-dind
- before_script:
- - printf '%s' "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- cache: []
- dependencies: []
-
-docker-build:
- extends: .docker
- resource_group: image-$CI_COMMIT_REF_SLUG
- script:
- # ponytail: GitLab slugs truncate at 63 bytes; add a branch-name hash if colliding names are needed.
- - docker build -t "$CI_REGISTRY_IMAGE:branch-$CI_COMMIT_REF_SLUG" -t "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" .
- - docker push "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA"
- - docker push "$CI_REGISTRY_IMAGE:branch-$CI_COMMIT_REF_SLUG"
- rules:
- - if: $CI_COMMIT_BRANCH
- - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_EVENT_TYPE == "detached" && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_ID
-
-docker-release:
- extends: .docker
- interruptible: false
- resource_group: stable-image
- script:
- - docker pull "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA"
- - docker tag "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- - docker tag "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:latest"
- - docker push "$CI_REGISTRY_IMAGE:latest"
- rules:
- - if: $CI_COMMIT_TAG
diff --git a/AGENTS.md b/AGENTS.md
index 0539ac5..47c0c0f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,6 +21,6 @@ This is a single-context repo using root `CONTEXT.md` and `docs/adr/`. See `docs
- Create `release/X.Y.Z` from `develop` and `hotfix/X.Y.Z` from `main`. Only `release/*` and `hotfix/*` PRs target `main`. Merge each release or hotfix into both `main` and `develop`, keeping its branch until both PRs are merged.
- Use merges and back-merges for releases and hotfixes. Never rebase `main`, `develop`, or a frozen release branch.
-CI conversion is pending in Gitea issue #268. Actions are disabled and protected branches require `gitlab-to-gitea/ci-conversion-required`; local checks do not satisfy this gate. See [Continuous integration](README.md#continuous-integration).
+The CI workflow is `.gitea/workflows/ci.yml`. Activation is tracked in Gitea issue #268; Actions is enabled with owner approval, and the migration gate stays required until live workflow validation and merge-message review. Before changing CI, image publication, or activation settings, read [Gitea Actions](docs/gitea-actions.md).
Before preparing a release or hotfix, creating a tag, or changing image publication, read [GitFlow and releases](README.md#gitflow-and-releases) for package versions, explicit stable tags, and image promotion.
diff --git a/README.md b/README.md
index c9b2ca0..356dcdd 100644
--- a/README.md
+++ b/README.md
@@ -238,24 +238,15 @@ The image builds the Angular application with Bun and Node, then runs the genera
## Continuous integration
-CI execution is frozen after the migration to Gitea, and Gitea Actions is disabled. [Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) tracks conversion of the preserved CI configuration to Gitea Actions. There is no checked-in `.gitea/workflows/` workflow yet.
+[`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) defines Gitea Actions validation, functional coverage, production builds, security scans, and Docker image publication. [Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) tracks activation. Actions is enabled with owner approval; publisher credentials and live workflow validation are still pending. The [activation guide](docs/gitea-actions.md) records runner, credential, permission, and merge-gate requirements.
-The preserved `.gitlab-ci.yml` defines validation, test, build, security, and image stages:
+The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, application/test type checks, Vitest coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
-- ESLint, Prettier, and TypeScript checks;
-- Vitest with coverage artifacts;
-- an optimized production build;
-- legacy GitLab SAST and Trivy filesystem scanning;
-- GitFlow, Conventional Commit, and release-version checks;
-- Docker image publication for successful branch pushes and same-project reviews.
+Functional tests use one worker and a 2 GiB Node heap. The separate benchmark job runs without coverage and keeps its five-minute protocol timeout. At the user's request, it has `continue-on-error: true` while [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) is open. #78 must restore the benchmark gate after the protocol passes. Passing functional checks during this exception does not establish benchmark acceptance.
-These definitions are migration inputs; they do not execute on Gitea. The policy script also still expects GitLab `CI_*` variables. Triggers, required checks, runner requirements, secrets, security scanning, image publication, and merge-message templates must be reviewed as part of #268 before the owner enables the converted workflow. Historical CI logs, reports, and downloadable artifacts were not imported; administrator rollback backups retain them.
+The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes and same-repository PRs. Registry credentials are confined to the publishing step.
-In the preserved configuration, the required `test` job runs application tests with coverage, one worker and a 2 GiB Node heap limit. A separate `benchmark` job uses the same environment outside coverage instrumentation, with the unchanged five-minute timeout and protocol assertions.
-
-At the user's request, the preserved benchmark job has `allow_failure: true` while [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) is open. Carry that advisory status into the converted workflow; #78 must restore the required benchmark gate after the protocol passes. Passing functional checks during this exception does not establish benchmark acceptance.
-
-Both protected branches require the unsatisfied status `gitlab-to-gitea/ci-conversion-required` until conversion is reviewed. Local validation does not satisfy this status; owner review and replacement of the migration gate are required before merges resume. See #268 for the migration requirements.
+Both protected branches still require `gitlab-to-gitea/ci-conversion-required` until the owner approves the first merge message and a successful Gitea PR run, then replaces the migration gate with the actual pull-request context for `required`. Local validation does not satisfy this status. Historical CI reports and artifacts remain in administrator rollback backups; the original configuration is retained in Git history.
### GitFlow and releases
@@ -263,18 +254,18 @@ Both protected branches require the unsatisfied status `gitlab-to-gitea/ci-conve
Create `release/X.Y.Z` from `develop` when the release scope is ready. Set `package.json` to `X.Y.Z` on this branch, stabilize it, then merge it into `main` and back into `develop`. Keep the release branch until both PRs have merged. A `hotfix/X.Y.Z` starts from `main`, changes the package version, and follows the same two-target merge process. Do not rebase `main`, `develop`, or a frozen release onto ongoing development.
-Use Conventional Commits for new commits and PR titles, for example `feat(calendar): add planning periods`, `fix(planner): handle expired balances`, or `chore(release): prepare 1.6.0`. Scopes are optional; `!` marks a breaking change. The merge commit subject must preserve the validated PR title. Gitea supports a [Git-based merge-message template](https://docs.gitea.com/usage/issues-prs/merge-message-templates/) at `.gitea/default_merge_message/MERGE_TEMPLATE.md`, using `${PullRequestTitle}` and `${PullRequestReference}`. Its introduction and an example expanded message are part of #268; legacy history is not rewritten.
+Use Conventional Commits for new commits and PR titles, for example `feat(calendar): add planning periods`, `fix(planner): handle expired balances`, or `chore(release): prepare 1.6.0`. Scopes are optional; `!` marks a breaking change. The merge commit subject must preserve the validated PR title. Gitea supports a [Git-based merge-message template](https://docs.gitea.com/usage/issues-prs/merge-message-templates/) at `.gitea/default_merge_message/MERGE_TEMPLATE.md`, using `${PullRequestTitle}` and `${PullRequestReference}`. The committed template and a checked example expansion are documented in the [activation guide](docs/gitea-actions.md); legacy history is not rewritten.
-The image tag contract to preserve during CI conversion is:
+The image publisher implements this tag contract in `gitea.nessar.fr/nessar/vac-optimizer`:
| Trigger | Published image tags |
| --- | --- |
| Successful push to any branch | `branch-` and `sha-` |
| Explicit release tag `vX.Y.Z` | `vX.Y.Z` and `latest`, using the existing SHA image |
-For example, `feature/calendar` uses `branch-feature-calendar`; integration uses `branch-develop`. Preserve the previous slug normalization: lowercase the branch name, replace characters other than `a-z` and `0-9` with `-`, truncate to 63 bytes, and remove leading/trailing `-`. Choose branch names with distinct slugs. Only valid release tags can update `latest`. Automatic image publication and tag promotion remain frozen until #268 is complete.
+For example, `feature/calendar` uses `branch-feature-calendar`; integration uses `branch-develop`. Preserve the previous slug normalization: lowercase the branch name, replace characters other than `a-z` and `0-9` with `-`, truncate to 63 bytes, and remove leading/trailing `-`. Choose branch names with distinct slugs. Only valid release tags can update `latest`. Automatic image publication and tag promotion begin after the owner provisions publisher credentials and validates publication permissions.
-After CI conversion is complete, the PR into `main` has merged, and its full checks and SHA image publication have succeeded, tag the published main commit:
+After owner activation, the PR into `main` has merged, and its full checks and SHA image publication have succeeded, tag the published main commit:
```bash
git fetch origin
@@ -284,11 +275,11 @@ git push origin v1.6.0
The tag must match the tagged commit's `package.json` version, use stable `vMAJOR.MINOR.PATCH` syntax, and point to a commit reachable from `main`. Wait for the main checks and image publication before tagging: promotion requires its `sha-` image. Tags do not increment the version automatically.
-The Gitea repository policy requires `develop` as the default branch and **Merge commit** as the merge method, with squash and rebase merge methods disabled. Configure [branch protection](https://docs.gitea.com/usage/access-control/protected-branches/) for `main` and `develop` to disable direct and forced pushes, restrict the merge allowlist to designated maintainers, require successful status checks, and block outdated PRs. Administrators must follow the protection rules. Replace the migration gate with the reviewed check contexts only after #268 is complete. These are required settings; the migration does not establish that every setting has already been applied.
+The Gitea repository policy requires `develop` as the default branch and **Merge commit** as the merge method, with squash and rebase merge methods disabled. Configure [branch protection](https://docs.gitea.com/usage/access-control/protected-branches/) for `main` and `develop` to disable direct and forced pushes, restrict the merge allowlist to designated maintainers, require successful status checks, and block outdated PRs. Administrators must follow the protection rules. After merge-message approval and a successful reviewed PR run, replace the migration gate with the reported pull-request context for the `required` job, as described in the activation guide. These are required settings; the migration does not establish that every setting has already been applied.
-Only `release/*` and `hotfix/*` PRs may target `main`; working PRs target `develop`. The converted CI must retain these rules and the rebase checks. If `develop` advances after validation, rebase and rerun the PR checks before merging.
+Only `release/*` and `hotfix/*` PRs may target `main`; working PRs target `develop`. CI validates these rules and checks working-branch rebases against the fetched integration head. If `develop` advances after validation, rebase and rerun the PR checks before merging.
-Run the preserved policy integration check locally with `node scripts/ci-policy.test.mjs`. It verifies the legacy policy adapter; it does not validate a Gitea workflow or satisfy the migration gate.
+Run `node scripts/ci-policy.test.mjs` and `node scripts/ci-image.test.mjs` locally to verify GitFlow event handling and image publication without writing to a registry. The adapter reads Gitea webhook data through the runner's `GITHUB_*` compatibility variables. Local checks do not satisfy the migration gate.
## Domain and design references
diff --git a/docs/benchmark/README.md b/docs/benchmark/README.md
index 90a5618..e9ea48a 100644
--- a/docs/benchmark/README.md
+++ b/docs/benchmark/README.md
@@ -19,14 +19,14 @@ verification. #78 owns benchmark verification and implementation of further
optimizations when needed, after #76 removes tracing and #77 verifies the retained
behavior. All seven scenarios, one warm-up and two measured repetitions per
scenario, the 2 GB heap limit and the 300-second timeout remain required for #78.
-At the user's request, the preserved CI configuration defines the benchmark in a
-separate job with `allow_failure: true` while #78 is open. Functional coverage
+At the user's request, the Gitea CI configuration defines the benchmark in a
+separate job with `continue-on-error: true` while #78 is open. Functional coverage
tests and all other required checks remain blocking. #78 must restore the required benchmark gate
after the unchanged protocol passes. A green pipeline during this exception
does not establish a passing benchmark. No automatic elapsed-time planning
-cutoff is introduced. CI execution is frozen and Gitea Actions is disabled pending
-[issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268); the separate
-advisory benchmark job must be carried into the converted workflow. See the
+cutoff is introduced. Gitea Actions is enabled; live workflow validation is tracked in
+[issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268). The converted
+workflow preserves the separate advisory benchmark job. See the
[CI migration status](../../README.md#continuous-integration).
The results below document earlier implementations and protocols. They are
diff --git a/docs/gitea-actions.md b/docs/gitea-actions.md
new file mode 100644
index 0000000..696f21b
--- /dev/null
+++ b/docs/gitea-actions.md
@@ -0,0 +1,131 @@
+# Gitea Actions conversion
+
+[Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) converts the
+repository's CI to [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml).
+The workflow and merge-message template have passed implementation review.
+Repository Actions is enabled with owner approval. Publisher credentials and
+live workflow validation are still pending.
+The existing `gitlab-to-gitea/ci-conversion-required` status stays in place until
+that review, a successful Gitea PR run, and approval of the prepared first merge
+message.
+
+## Jobs and triggers
+
+Every branch and tag push runs CI. Pull requests run on opening, reopening,
+synchronization, and title/body edits. Manual dispatch runs validation without
+publishing images. There is no schedule or deployment job in the preserved
+configuration; the Compose files remain manual deployment options.
+
+| Job | Behavior |
+| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `policy` | Tests the policy and publisher, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
+| `validate` | Runs lint, formatting, and application/test type checks. |
+| `test` | Runs functional coverage with one worker and a 2 GiB Node heap; uploads coverage for seven days even on failure. |
+| `benchmark` | Runs outside coverage with the same worker/heap limits and 300-second protocol timeout. Advisory while #78 is open. |
+| `build` | Builds browser/server production bundles and retains `dist` for seven days. |
+| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. |
+| `images` | Publishes branch/SHA images after required validation, or promotes an existing SHA image for a stable tag. Runs only for pushes and same-repository PRs. |
+| `required` | Fails on failed, cancelled, or skipped required validation. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. Excludes the advisory benchmark. |
+
+Push and PR runs are separate so PR title and rebase checks run on the PR event.
+New runs cancel older runs for the same event/ref. Image jobs serialize per
+branch slug; stable promotions share one group and are not cancelled in progress.
+
+## Runners and permissions
+
+Validation uses Node 24.21.0 and Bun 1.4.2 with the frozen lockfile. The image
+job uses Docker 29.8.2 and its matching Docker-in-Docker service. Scanner
+versions are Semgrep 1.180.0 and Trivy 0.75.0; Trivy downloads are checksum-checked.
+Jobs select the `ubuntu-latest` label and require Linux container-job support.
+Allow at least the preserved 2 GiB Node heap plus runner and container overhead.
+
+The instance currently has an online inherited runner named `srvhome`, labelled
+`ubuntu-latest` and `ubuntu-24.04`. Repository runners, secrets, and variables
+were empty at implementation time. The owner must verify that this runner can
+run the job containers, JavaScript actions, artifact uploads, and the privileged
+Docker-in-Docker service before replacing the migration gate. Use isolated runners for untrusted
+contributor code; the privileged publishing service belongs to trusted runs.
+
+The workflow requests a read-only repository token. Checkout removes persisted
+credentials; publication credentials are passed only to the image step. Fork PRs
+and manual dispatches cannot run that step. Gitea's job token cannot publish
+container packages, so provision the two repository Actions secrets below.
+See [job-token permissions](https://docs.gitea.com/usage/actions/token-permissions/)
+and [package authorization](https://docs.gitea.com/usage/actions/comparison/).
+
+| Secret | Purpose |
+| ------------------- | --------------------------------------------------------------------------- |
+| `REGISTRY_USERNAME` | The package publisher's Gitea username, authorized to publish for `nessar`. |
+| `REGISTRY_PASSWORD` | A dedicated personal access token with `write:package` permission. |
+
+Keep secret values in Gitea's Actions secret store. The configured tea account's
+administrator token is not a CI publishing credential. No SSH or deployment
+secret is required by this workflow.
+
+## Images and releases
+
+Images use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
+at `gitea.nessar.fr/nessar/vac-optimizer`. Branch pushes and same-repository PRs
+publish `branch-` and `sha-`. Slugs retain the previous
+lowercase, non-alphanumeric-to-hyphen, 63-byte normalization; choose distinct
+branch slugs.
+
+Only stable `vX.Y.Z` tags matching the tagged commit's `package.json` version
+and reachable from `main` may promote images. Promotion first pulls the existing
+SHA image, then publishes the version tag and `latest`; it never rebuilds.
+An absent SHA image fails before either stable tag changes. Wait for the main
+image job before tagging. No version increment or deployment is automatic.
+
+## Merge messages and activation
+
+The [merge template](../.gitea/default_merge_message/MERGE_TEMPLATE.md) translates
+the previous title, closing-issue references, and review reference using
+[Gitea's supported variables](https://docs.gitea.com/usage/issues-prs/merge-message-templates/).
+For a PR titled `ci: migrate to Gitea Actions`, closing #268, with reference #271,
+the checked expansion is:
+
+```text
+ci: migrate to Gitea Actions
+
+close #268
+
+See pull request #271
+```
+
+The owner has enabled Actions and approved validation before adding publisher
+secrets. Complete the remaining activation checks in this order:
+
+1. Review this workflow against the protected administrator exports for runner
+ requirements, triggers/schedules, permissions, secrets, publishing, and
+ deployment dependencies. The original CI definition is retained in Git:
+ `git show 0d900fd72d1a405d257f5a566c55cc0cb3d2bc61:.gitlab-ci.yml`.
+2. Publish the reviewed feature branch and open its PR to register the workflow.
+ Once registered, manual dispatch verifies validation and artifacts without
+ publishing images. Push and PR validation can also run now; their image and
+ aggregate checks remain blocked until publisher secrets are provisioned.
+3. Verify runner isolation and Docker service support, then provision the two
+ publisher secrets. Review the package publisher's permissions and registry
+ namespace before permitting publication. Rerun the PR workflow and require a
+ successful **pull-request run** on the final commit, including PR title and
+ rebase checks, artifact handling, and actual image publication. A push run
+ or manual run alone does not validate the PR policy and publication.
+4. Prepare and review the first PR's merge message before replacing the migration
+ gate. The target branch will not contain the new template until that first
+ merge, so explicitly use the validated PR title, closing references, and PR
+ reference for its merge message. Keep the migration gate throughout this
+ review.
+5. After the successful PR run and merge-message approval, replace the migration
+ status requirement on both protected branches with the **actual reported
+ pull-request context** for the `required` job. Select it from the PR's reported
+ checks rather than a push run or a guessed context spelling. Fork PRs must
+ also be able to satisfy this check without base-repository push permissions.
+ Keep direct/force pushes disabled, merges restricted to maintainers, outdated
+ PRs blocked, and administrator protection bypass disabled. Select merge
+ commits and disable squash/rebase merge methods.
+6. Merge the first PR using the approved message, then verify the resulting merge
+ commit. Subsequent merges read the committed template. No direct
+ integration-branch push is needed.
+
+Historical job execution, reports, and downloadable artifacts remain in the
+administrator rollback backups. They are not recreated or uploaded by this
+conversion.
diff --git a/scripts/ci-context.mjs b/scripts/ci-context.mjs
new file mode 100644
index 0000000..a1052ea
--- /dev/null
+++ b/scripts/ci-context.mjs
@@ -0,0 +1,35 @@
+import { readFileSync } from 'node:fs';
+
+// Gitea exposes its event payload through the GitHub-compatible runner variables.
+export function readCiContext() {
+ const env = process.env;
+ const eventName = env.GITHUB_EVENT_NAME;
+ if (!['push', 'pull_request', 'workflow_dispatch'].includes(eventName)) {
+ throw new Error('Only push, pull_request and workflow_dispatch events are supported.');
+ }
+ const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
+ const commit = env.GITHUB_SHA;
+ if (!/^[a-f0-9]{40}$/.test(commit ?? '')) throw new Error('A commit SHA is required.');
+ const pullRequest = eventName === 'pull_request' ? event.pull_request : undefined;
+ if (eventName === 'pull_request' && pullRequest?.head?.sha !== commit) {
+ throw new Error('CI must validate the pull request head commit.');
+ }
+ const tag = env.GITHUB_REF?.startsWith('refs/tags/') ? env.GITHUB_REF.slice(10) : '';
+ const branch = pullRequest
+ ? pullRequest.head.ref
+ : env.GITHUB_REF?.startsWith('refs/heads/')
+ ? env.GITHUB_REF.slice(11)
+ : '';
+ if (!tag && !branch) throw new Error('A branch or tag ref is required.');
+ const slug = branch
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, '-')
+ .slice(0, 63)
+ .replace(/^-+|-+$/g, '');
+ const publish =
+ eventName === 'push' ||
+ (eventName === 'pull_request' &&
+ Boolean(env.GITHUB_REPOSITORY) &&
+ pullRequest.head.repo?.full_name === env.GITHUB_REPOSITORY);
+ return { commit, tag, branch, slug, publish, pullRequest, before: event.before };
+}
diff --git a/scripts/ci-image.mjs b/scripts/ci-image.mjs
new file mode 100644
index 0000000..3c95bc6
--- /dev/null
+++ b/scripts/ci-image.mjs
@@ -0,0 +1,40 @@
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { readCiContext } from './ci-context.mjs';
+
+try {
+ const { commit, tag, slug, publish } = readCiContext();
+ if (!publish) throw new Error('Images can only be published by pushes or same-repository PRs.');
+ execFileSync(process.execPath, [fileURLToPath(new URL('./ci-policy.mjs', import.meta.url))]);
+ const env = process.env;
+ if (!env.REGISTRY_USERNAME || !env.REGISTRY_PASSWORD) {
+ throw new Error('REGISTRY_USERNAME and REGISTRY_PASSWORD are required for publication.');
+ }
+ const registry = new URL(env.GITHUB_SERVER_URL).host;
+ const image = `${registry}/${env.GITHUB_REPOSITORY.toLowerCase()}`;
+ const shaImage = `${image}:sha-${commit}`;
+ const docker = (...args) => execFileSync('docker', args, { stdio: 'inherit' });
+ execFileSync(
+ 'docker',
+ ['login', registry, '--username', env.REGISTRY_USERNAME, '--password-stdin'],
+ {
+ input: env.REGISTRY_PASSWORD,
+ stdio: ['pipe', 'inherit', 'inherit'],
+ },
+ );
+ if (tag) {
+ docker('pull', shaImage);
+ docker('tag', shaImage, `${image}:${tag}`);
+ docker('push', `${image}:${tag}`);
+ docker('tag', shaImage, `${image}:latest`);
+ docker('push', `${image}:latest`);
+ } else {
+ const branchImage = `${image}:branch-${slug}`;
+ docker('build', '-t', shaImage, '-t', branchImage, '.');
+ docker('push', shaImage);
+ docker('push', branchImage);
+ }
+} catch (error) {
+ console.error(error.message);
+ process.exitCode = 1;
+}
diff --git a/scripts/ci-image.test.mjs b/scripts/ci-image.test.mjs
new file mode 100644
index 0000000..ae0a444
--- /dev/null
+++ b/scripts/ci-image.test.mjs
@@ -0,0 +1,137 @@
+import assert from 'node:assert/strict';
+import { execFileSync, spawnSync } from 'node:child_process';
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const directory = mkdtempSync(join(tmpdir(), 'ci-image-'));
+const script = fileURLToPath(new URL('./ci-image.mjs', import.meta.url));
+const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim();
+const image = 'gitea.nessar.fr/nessar/vac-optimizer';
+const commandsPath = join(directory, 'docker-commands.jsonl');
+const bin = join(directory, 'bin');
+
+const run = (variables = {}, expected = 0) => {
+ const commit = git('rev-parse', 'HEAD');
+ const eventName = variables.eventName ?? 'push';
+ const ref = variables.tag
+ ? `refs/tags/${variables.tag}`
+ : eventName === 'pull_request'
+ ? 'refs/pull/42/head'
+ : 'refs/heads/feature/calendar';
+ const event = { ref };
+ if (eventName === 'pull_request') {
+ event.pull_request = {
+ title: 'feat: calendar',
+ head: {
+ sha: commit,
+ ref: 'feature/calendar',
+ repo: { full_name: variables.headRepository ?? 'nessar/vac-optimizer' },
+ },
+ base: { ref: 'develop' },
+ };
+ }
+ writeFileSync(join(directory, 'event.json'), JSON.stringify(event));
+ writeFileSync(commandsPath, '');
+ const result = spawnSync(process.execPath, [script], {
+ cwd: directory,
+ encoding: 'utf8',
+ env: {
+ PATH: `${bin}:${process.env.PATH}`,
+ GITHUB_EVENT_NAME: eventName,
+ GITHUB_EVENT_PATH: join(directory, 'event.json'),
+ GITHUB_SHA: commit,
+ GITHUB_REF: ref,
+ GITHUB_REPOSITORY: 'nessar/vac-optimizer',
+ GITHUB_SERVER_URL: 'https://gitea.nessar.fr',
+ REGISTRY_USERNAME: 'publisher',
+ REGISTRY_PASSWORD: variables.registryPassword ?? 'test-token',
+ DOCKER_COMMANDS: commandsPath,
+ MISSING_IMAGE: variables.missingImage ? 'true' : '',
+ },
+ });
+ assert.equal(result.status, expected, result.stdout + result.stderr);
+ return readFileSync(commandsPath, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
+};
+
+try {
+ git('init', '--initial-branch=main');
+ git('config', 'user.name', 'CI image test');
+ git('config', 'user.email', 'ci-image@example.invalid');
+ git('remote', 'add', 'origin', directory);
+ writeFileSync(join(directory, 'package.json'), '{"version":"1.5.0"}\n');
+ git('add', 'package.json');
+ git('commit', '-m', 'chore: initial release');
+ git('branch', 'develop');
+ git('switch', '-c', 'feature/calendar');
+ git('commit', '--allow-empty', '-m', 'feat: calendar');
+ mkdirSync(bin);
+ const docker = join(bin, 'docker');
+ writeFileSync(
+ docker,
+ `#!/usr/bin/env node
+import { appendFileSync, readFileSync } from 'node:fs';
+const args = process.argv.slice(2);
+const input = args[0] === 'login' ? readFileSync(0, 'utf8') : '';
+appendFileSync(process.env.DOCKER_COMMANDS, JSON.stringify({ args, input }) + '\\n');
+if (args[0] === 'pull' && process.env.MISSING_IMAGE === 'true') process.exit(1);
+`,
+ );
+ chmodSync(docker, 0o755);
+ const commit = git('rev-parse', 'HEAD');
+ const commands = run();
+ assert.deepEqual(commands, [
+ {
+ args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
+ input: 'test-token',
+ },
+ {
+ args: [
+ 'build',
+ '-t',
+ `${image}:sha-${commit}`,
+ '-t',
+ `${image}:branch-feature-calendar`,
+ '.',
+ ],
+ input: '',
+ },
+ { args: ['push', `${image}:sha-${commit}`], input: '' },
+ { args: ['push', `${image}:branch-feature-calendar`], input: '' },
+ ]);
+ assert.deepEqual(run({ eventName: 'pull_request' }), commands);
+ assert.deepEqual(
+ run({ eventName: 'pull_request', headRepository: 'contributor/vac-optimizer' }, 1),
+ [],
+ );
+ assert.deepEqual(run({ eventName: 'workflow_dispatch' }, 1), []);
+ assert.deepEqual(run({ registryPassword: '' }, 1), []);
+ assert.deepEqual(run({ tag: 'v1.5.0' }, 1), []);
+ git('switch', 'main');
+ git('tag', '-a', 'v1.5.0', '-m', 'Release 1.5.0');
+ const releaseCommit = git('rev-parse', 'HEAD');
+ assert.deepEqual(run({ tag: 'v1.5.0' }), [
+ {
+ args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
+ input: 'test-token',
+ },
+ { args: ['pull', `${image}:sha-${releaseCommit}`], input: '' },
+ { args: ['tag', `${image}:sha-${releaseCommit}`, `${image}:v1.5.0`], input: '' },
+ { args: ['push', `${image}:v1.5.0`], input: '' },
+ { args: ['tag', `${image}:sha-${releaseCommit}`, `${image}:latest`], input: '' },
+ { args: ['push', `${image}:latest`], input: '' },
+ ]);
+ assert.deepEqual(run({ tag: 'v1.5.0', missingImage: true }, 1), [
+ {
+ args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
+ input: 'test-token',
+ },
+ { args: ['pull', `${image}:sha-${releaseCommit}`], input: '' },
+ ]);
+ assert.deepEqual(run({ tag: 'v1.5.0-rc.1' }, 1), []);
+ assert.deepEqual(run({ tag: 'v1.6.0' }, 1), []);
+ console.log('CI image publication checks passed.');
+} finally {
+ rmSync(directory, { recursive: true, force: true });
+}
diff --git a/scripts/ci-policy.mjs b/scripts/ci-policy.mjs
index 7ec2515..82392ad 100644
--- a/scripts/ci-policy.mjs
+++ b/scripts/ci-policy.mjs
@@ -1,5 +1,6 @@
import { execFileSync, spawnSync } from 'node:child_process';
-import { readFileSync } from 'node:fs';
+import { appendFileSync, readFileSync } from 'node:fs';
+import { readCiContext } from './ci-context.mjs';
const env = process.env;
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
@@ -11,7 +12,7 @@ const requirePolicy = (condition, message) => {
};
try {
- const commit = env.CI_COMMIT_SHA;
+ const { commit, tag, branch, slug, pullRequest, before } = readCiContext();
requirePolicy(commit === git('rev-parse', 'HEAD'), 'CI must validate the checked-out commit.');
git(
'fetch',
@@ -21,33 +22,32 @@ try {
'+refs/heads/develop:refs/remotes/origin/develop',
);
- if (env.CI_COMMIT_TAG) {
+ if (tag) {
requirePolicy(
- /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(env.CI_COMMIT_TAG),
+ /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tag),
'Release tags must use vMAJOR.MINOR.PATCH without leading zeroes.',
);
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
- requirePolicy(env.CI_COMMIT_TAG === `v${version}`, 'Release tag must match package.json.');
+ requirePolicy(tag === `v${version}`, 'Release tag must match package.json.');
requirePolicy(ancestor(commit, 'origin/main'), 'Release commit must belong to main.');
} else {
- const mr = env.CI_PIPELINE_SOURCE === 'merge_request_event';
- const branch = mr ? env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME : env.CI_COMMIT_BRANCH;
- const target = mr
- ? env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME
+ const target = pullRequest
+ ? pullRequest.base?.ref
: branch === 'main' || branch?.startsWith('hotfix/')
? 'main'
: 'develop';
requirePolicy(branch, 'A branch name is required.');
- requirePolicy(['main', 'develop'].includes(target), 'MRs must target main or develop.');
+ git('check-ref-format', `refs/heads/${branch}`);
+ requirePolicy(['main', 'develop'].includes(target), 'PRs must target main or develop.');
- if (mr) {
- const title = env.CI_MERGE_REQUEST_TITLE?.replace(
+ if (pullRequest) {
+ const title = pullRequest.title?.replace(
/^(?:Draft:|WIP:|\[Draft\]|\[WIP\]|\(Draft\)|\(WIP\))\s*/i,
'',
);
requirePolicy(
conventional.test(title ?? ''),
- 'MR title must use Conventional Commits.',
+ 'PR title must use Conventional Commits.',
);
requirePolicy(
target !== 'main' || /^(release|hotfix)\/.+/.test(branch),
@@ -65,9 +65,8 @@ try {
}
}
- const before = env.CI_COMMIT_BEFORE_SHA;
const base =
- !mr && before && !/^0+$/.test(before) && ancestor(before, commit)
+ !pullRequest && before && !/^0+$/.test(before) && ancestor(before, commit)
? before
: git('merge-base', `origin/${target}`, commit);
const subjects = git('log', '--format=%h %s', `${base}..${commit}`);
@@ -78,6 +77,9 @@ try {
);
}
}
+ if (env.GITHUB_OUTPUT) {
+ appendFileSync(env.GITHUB_OUTPUT, `slug=${slug}\ntag=${tag}\n`);
+ }
console.log('GitFlow, version and commit policy passed.');
} catch (error) {
console.error(error.message);
diff --git a/scripts/ci-policy.test.mjs b/scripts/ci-policy.test.mjs
index e3ef862..4a0f4ab 100644
--- a/scripts/ci-policy.test.mjs
+++ b/scripts/ci-policy.test.mjs
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -8,26 +8,48 @@ import { fileURLToPath } from 'node:url';
const directory = mkdtempSync(join(tmpdir(), 'ci-policy-'));
const script = fileURLToPath(new URL('./ci-policy.mjs', import.meta.url));
const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim();
-const check = (variables, expected = 0) => {
+const check = (variables = {}, expected = 0) => {
+ const commit = git('rev-parse', 'HEAD');
+ const branch = variables.branch ?? 'feature/test';
+ const eventName = variables.eventName ?? 'push';
+ const ref = variables.tag
+ ? `refs/tags/${variables.tag}`
+ : eventName === 'pull_request'
+ ? 'refs/pull/42/head'
+ : `refs/heads/${branch}`;
+ const event = { ref, before: variables.before };
+ if (eventName === 'pull_request') {
+ event.pull_request = {
+ title: variables.title ?? 'feat(test): add policy checks',
+ head: {
+ ref: branch,
+ sha: variables.headSha ?? commit,
+ repo: { full_name: variables.headRepository ?? 'nessar/vac-optimizer' },
+ },
+ base: { ref: variables.target ?? 'develop' },
+ };
+ }
+ const eventPath = join(directory, 'event.json');
+ const outputPath = join(directory, 'outputs');
+ writeFileSync(eventPath, JSON.stringify(event));
+ writeFileSync(outputPath, '');
const result = spawnSync(process.execPath, [script], {
cwd: directory,
encoding: 'utf8',
env: {
PATH: process.env.PATH,
- CI_PIPELINE_SOURCE: 'push',
- CI_COMMIT_SHA: git('rev-parse', 'HEAD'),
- CI_COMMIT_BRANCH: 'feature/test',
- ...variables,
+ GITHUB_EVENT_NAME: eventName,
+ GITHUB_EVENT_PATH: eventPath,
+ GITHUB_REF: ref,
+ GITHUB_SHA: variables.sha ?? commit,
+ GITHUB_REPOSITORY: 'nessar/vac-optimizer',
+ GITHUB_OUTPUT: outputPath,
},
});
assert.equal(result.status, expected, result.stdout + result.stderr);
+ return readFileSync(outputPath, 'utf8');
};
-const mr = {
- CI_PIPELINE_SOURCE: 'merge_request_event',
- CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'feature/test',
- CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'develop',
- CI_MERGE_REQUEST_TITLE: 'feat(test): add policy checks',
-};
+const pr = { eventName: 'pull_request' };
try {
git('init', '--initial-branch=main');
@@ -41,26 +63,31 @@ try {
git('switch', '-c', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat(test)!: add checks');
check({});
- check(mr);
- check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Draft: feat: test policy' });
- check({ ...mr, CI_MERGE_REQUEST_TITLE: '(Draft) feat: test policy' });
- check({ ...mr, CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main' }, 1);
- check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Add checks' }, 1);
- check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
- check({ CI_COMMIT_SHA: '0'.repeat(40) }, 1);
+ check(pr);
+ check({ ...pr, headRepository: 'contributor/vac-optimizer' });
+ check({ eventName: 'workflow_dispatch' });
+ assert.match(check({ branch: 'Feature/My_calendar' }), /slug=feature-my-calendar\n/);
+ check({ ...pr, headSha: '0'.repeat(40) }, 1);
+ check({ eventName: 'pull_request_target' }, 1);
+ check({ ...pr, title: 'Draft: feat: test policy' });
+ check({ ...pr, title: '(Draft) feat: test policy' });
+ check({ ...pr, target: 'main' }, 1);
+ check({ ...pr, title: 'Add checks' }, 1);
+ check({ tag: 'v1.5.0' }, 1);
+ check({ sha: '0'.repeat(40) }, 1);
git('switch', 'develop');
git('commit', '--allow-empty', '-m', 'fix: another change');
git('switch', 'feature/test');
- check(mr, 1);
+ check(pr, 1);
git('rebase', 'develop');
- check(mr);
+ check(pr);
const before = git('rev-parse', 'HEAD');
git('commit', '--allow-empty', '-m', 'not conventional');
- check({ CI_COMMIT_BEFORE_SHA: before }, 1);
- check(mr, 1);
+ check({ before: before }, 1);
+ check(pr, 1);
git('commit', '--amend', '--allow-empty', '-m', 'ci: fix the commit title');
- check({ CI_COMMIT_BEFORE_SHA: before });
+ check({ before: before });
git('switch', 'develop');
git('merge', '--no-ff', 'feature/test', '-m', 'feat(test): add policy checks');
@@ -69,33 +96,33 @@ try {
git('add', 'package.json');
git('commit', '-m', 'chore(release): prepare 1.6.0');
check({
- ...mr,
- CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0',
- CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
+ ...pr,
+ branch: 'release/1.6.0',
+ target: 'main',
});
- check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0' });
+ check({ ...pr, branch: 'release/1.6.0' });
git('switch', 'main');
git('merge', '--no-ff', 'release/1.6.0', '-m', 'chore(release): publish 1.6.0');
- check({ CI_COMMIT_TAG: 'v1.6.0' });
- check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
- check({ CI_COMMIT_TAG: 'v01.6.0' }, 1);
- check({ CI_COMMIT_TAG: 'v1.6.0-rc.1' }, 1);
- check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'main' });
+ check({ tag: 'v1.6.0' });
+ check({ tag: 'v1.5.0' }, 1);
+ check({ tag: 'v01.6.0' }, 1);
+ check({ tag: 'v1.6.0-rc.1' }, 1);
+ check({ ...pr, branch: 'main' });
git('switch', '-c', 'hotfix/1.6.1');
git('commit', '--allow-empty', '-m', 'fix: production issue');
check({
- ...mr,
- CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1',
- CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
+ ...pr,
+ branch: 'hotfix/1.6.1',
+ target: 'main',
});
- check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1' });
+ check({ ...pr, branch: 'hotfix/1.6.1' });
// The working branch must not pass by merging develop instead of rebasing.
git('switch', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat: keep working');
git('merge', '--no-ff', 'develop', '-m', 'chore: merge develop');
- check(mr, 1);
+ check(pr, 1);
console.log('CI policy integration checks passed.');
} finally {