ci: migrate to Gitea Actions
CI / policy (push) Successful in 48s
CI / validate (push) Successful in 1m25s
CI / test (push) Successful in 3m43s
CI / build (push) Successful in 1m20s
CI / security (push) Successful in 1m31s
CI / benchmark (push) Failing after 5m52s
CI / images (push) Failing after 1m2s
CI / required (push) Failing after 1s
CI / policy (pull_request) Successful in 9s
CI / validate (pull_request) Successful in 1m6s
CI / test (pull_request) Successful in 3m30s
CI / benchmark (pull_request) Canceled after 3s
CI / build (pull_request) Successful in 1m5s
CI / security (pull_request) Successful in 1m27s
CI / images (pull_request) Successful in 2m24s
CI / required (pull_request) Successful in 5s

This commit is contained in:
nessar committed 2026-10-13 19:43:08 +02:00
1 parent 7d60157863
commit d74dfb9938
14 files changed
+662 -262

No files matched your search

+1 -1
View File
@@ -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: 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. - 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. Then show the user the ordered list of stages and the values each produces, and confirm — they may add, drop, or reorder.
+3 -2
View File
@@ -26,10 +26,11 @@ All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to
``` ```
4. **CI Checks**: 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) - **Linting check**: `bun run lint` (or use `bun run lint:fix` to resolve auto-fixable issues)
- **Formatting check**: `bun run format:check` - **Formatting check**: `bun run format:check`
- **TypeScript Type check**: `bunx tsc --noEmit -p tsconfig.app.json` and `bunx tsc --noEmit -p tsconfig.spec.json` - **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` - **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`. - 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`.
@@ -0,0 +1,5 @@
${PullRequestTitle}
${ClosingIssues}
See pull request ${PullRequestReference}
+210
View File
@@ -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
-179
View File
@@ -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
+1 -1
View File
@@ -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. - 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. - 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. 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.
+12 -21
View File
@@ -238,24 +238,15 @@ The image builds the Angular application with Bun and Node, then runs the genera
## Continuous integration ## 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; 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.
- 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.
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. 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.
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.
### GitFlow and releases ### 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. 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 | | Trigger | Published image tags |
| --- | --- | | --- | --- |
| Successful push to any branch | `branch-<normalized branch slug>` and `sha-<full commit SHA>` | | Successful push to any branch | `branch-<normalized branch slug>` and `sha-<full commit SHA>` |
| Explicit release tag `vX.Y.Z` | `vX.Y.Z` and `latest`, using the existing SHA image | | 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 ```bash
git fetch origin 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-<commit>` image. Tags do not increment the version automatically. 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-<commit>` 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 ## Domain and design references
+5 -5
View File
@@ -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 optimizations when needed, after #76 removes tracing and #77 verifies the retained
behavior. All seven scenarios, one warm-up and two measured repetitions per 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. 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 At the user's request, the Gitea CI configuration defines the benchmark in a
separate job with `allow_failure: true` while #78 is open. Functional coverage 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 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 after the unchanged protocol passes. A green pipeline during this exception
does not establish a passing benchmark. No automatic elapsed-time planning does not establish a passing benchmark. No automatic elapsed-time planning
cutoff is introduced. CI execution is frozen and Gitea Actions is disabled pending 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 separate [issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268). The converted
advisory benchmark job must be carried into the converted workflow. See the workflow preserves the separate advisory benchmark job. See the
[CI migration status](../../README.md#continuous-integration). [CI migration status](../../README.md#continuous-integration).
The results below document earlier implementations and protocols. They are The results below document earlier implementations and protocols. They are
+131
View File
@@ -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-<slug>` and `sha-<full commit 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.
+35
View File
@@ -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 };
}
+40
View File
@@ -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;
}
+137
View File
@@ -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 });
}
+17 -15
View File
@@ -1,5 +1,6 @@
import { execFileSync, spawnSync } from 'node:child_process'; 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 env = process.env;
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
@@ -11,7 +12,7 @@ const requirePolicy = (condition, message) => {
}; };
try { 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.'); requirePolicy(commit === git('rev-parse', 'HEAD'), 'CI must validate the checked-out commit.');
git( git(
'fetch', 'fetch',
@@ -21,33 +22,32 @@ try {
'+refs/heads/develop:refs/remotes/origin/develop', '+refs/heads/develop:refs/remotes/origin/develop',
); );
if (env.CI_COMMIT_TAG) { if (tag) {
requirePolicy( 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.', 'Release tags must use vMAJOR.MINOR.PATCH without leading zeroes.',
); );
const { version } = JSON.parse(readFileSync('package.json', 'utf8')); 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.'); requirePolicy(ancestor(commit, 'origin/main'), 'Release commit must belong to main.');
} else { } else {
const mr = env.CI_PIPELINE_SOURCE === 'merge_request_event'; const target = pullRequest
const branch = mr ? env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME : env.CI_COMMIT_BRANCH; ? pullRequest.base?.ref
const target = mr
? env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME
: branch === 'main' || branch?.startsWith('hotfix/') : branch === 'main' || branch?.startsWith('hotfix/')
? 'main' ? 'main'
: 'develop'; : 'develop';
requirePolicy(branch, 'A branch name is required.'); 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) { if (pullRequest) {
const title = env.CI_MERGE_REQUEST_TITLE?.replace( const title = pullRequest.title?.replace(
/^(?:Draft:|WIP:|\[Draft\]|\[WIP\]|\(Draft\)|\(WIP\))\s*/i, /^(?:Draft:|WIP:|\[Draft\]|\[WIP\]|\(Draft\)|\(WIP\))\s*/i,
'', '',
); );
requirePolicy( requirePolicy(
conventional.test(title ?? ''), conventional.test(title ?? ''),
'MR title must use Conventional Commits.', 'PR title must use Conventional Commits.',
); );
requirePolicy( requirePolicy(
target !== 'main' || /^(release|hotfix)\/.+/.test(branch), target !== 'main' || /^(release|hotfix)\/.+/.test(branch),
@@ -65,9 +65,8 @@ try {
} }
} }
const before = env.CI_COMMIT_BEFORE_SHA;
const base = const base =
!mr && before && !/^0+$/.test(before) && ancestor(before, commit) !pullRequest && before && !/^0+$/.test(before) && ancestor(before, commit)
? before ? before
: git('merge-base', `origin/${target}`, commit); : git('merge-base', `origin/${target}`, commit);
const subjects = git('log', '--format=%h %s', `${base}..${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.'); console.log('GitFlow, version and commit policy passed.');
} catch (error) { } catch (error) {
console.error(error.message); console.error(error.message);
+65 -38
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process'; 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 { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -8,26 +8,48 @@ import { fileURLToPath } from 'node:url';
const directory = mkdtempSync(join(tmpdir(), 'ci-policy-')); const directory = mkdtempSync(join(tmpdir(), 'ci-policy-'));
const script = fileURLToPath(new URL('./ci-policy.mjs', import.meta.url)); const script = fileURLToPath(new URL('./ci-policy.mjs', import.meta.url));
const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim(); 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], { const result = spawnSync(process.execPath, [script], {
cwd: directory, cwd: directory,
encoding: 'utf8', encoding: 'utf8',
env: { env: {
PATH: process.env.PATH, PATH: process.env.PATH,
CI_PIPELINE_SOURCE: 'push', GITHUB_EVENT_NAME: eventName,
CI_COMMIT_SHA: git('rev-parse', 'HEAD'), GITHUB_EVENT_PATH: eventPath,
CI_COMMIT_BRANCH: 'feature/test', GITHUB_REF: ref,
...variables, GITHUB_SHA: variables.sha ?? commit,
GITHUB_REPOSITORY: 'nessar/vac-optimizer',
GITHUB_OUTPUT: outputPath,
}, },
}); });
assert.equal(result.status, expected, result.stdout + result.stderr); assert.equal(result.status, expected, result.stdout + result.stderr);
return readFileSync(outputPath, 'utf8');
}; };
const mr = { const pr = { eventName: 'pull_request' };
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',
};
try { try {
git('init', '--initial-branch=main'); git('init', '--initial-branch=main');
@@ -41,26 +63,31 @@ try {
git('switch', '-c', 'feature/test'); git('switch', '-c', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat(test)!: add checks'); git('commit', '--allow-empty', '-m', 'feat(test)!: add checks');
check({}); check({});
check(mr); check(pr);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Draft: feat: test policy' }); check({ ...pr, headRepository: 'contributor/vac-optimizer' });
check({ ...mr, CI_MERGE_REQUEST_TITLE: '(Draft) feat: test policy' }); check({ eventName: 'workflow_dispatch' });
check({ ...mr, CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main' }, 1); assert.match(check({ branch: 'Feature/My_calendar' }), /slug=feature-my-calendar\n/);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Add checks' }, 1); check({ ...pr, headSha: '0'.repeat(40) }, 1);
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1); check({ eventName: 'pull_request_target' }, 1);
check({ CI_COMMIT_SHA: '0'.repeat(40) }, 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('switch', 'develop');
git('commit', '--allow-empty', '-m', 'fix: another change'); git('commit', '--allow-empty', '-m', 'fix: another change');
git('switch', 'feature/test'); git('switch', 'feature/test');
check(mr, 1); check(pr, 1);
git('rebase', 'develop'); git('rebase', 'develop');
check(mr); check(pr);
const before = git('rev-parse', 'HEAD'); const before = git('rev-parse', 'HEAD');
git('commit', '--allow-empty', '-m', 'not conventional'); git('commit', '--allow-empty', '-m', 'not conventional');
check({ CI_COMMIT_BEFORE_SHA: before }, 1); check({ before: before }, 1);
check(mr, 1); check(pr, 1);
git('commit', '--amend', '--allow-empty', '-m', 'ci: fix the commit title'); git('commit', '--amend', '--allow-empty', '-m', 'ci: fix the commit title');
check({ CI_COMMIT_BEFORE_SHA: before }); check({ before: before });
git('switch', 'develop'); git('switch', 'develop');
git('merge', '--no-ff', 'feature/test', '-m', 'feat(test): add policy checks'); git('merge', '--no-ff', 'feature/test', '-m', 'feat(test): add policy checks');
@@ -69,33 +96,33 @@ try {
git('add', 'package.json'); git('add', 'package.json');
git('commit', '-m', 'chore(release): prepare 1.6.0'); git('commit', '-m', 'chore(release): prepare 1.6.0');
check({ check({
...mr, ...pr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0', branch: 'release/1.6.0',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main', 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('switch', 'main');
git('merge', '--no-ff', 'release/1.6.0', '-m', 'chore(release): publish 1.6.0'); git('merge', '--no-ff', 'release/1.6.0', '-m', 'chore(release): publish 1.6.0');
check({ CI_COMMIT_TAG: 'v1.6.0' }); check({ tag: 'v1.6.0' });
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1); check({ tag: 'v1.5.0' }, 1);
check({ CI_COMMIT_TAG: 'v01.6.0' }, 1); check({ tag: 'v01.6.0' }, 1);
check({ CI_COMMIT_TAG: 'v1.6.0-rc.1' }, 1); check({ tag: 'v1.6.0-rc.1' }, 1);
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'main' }); check({ ...pr, branch: 'main' });
git('switch', '-c', 'hotfix/1.6.1'); git('switch', '-c', 'hotfix/1.6.1');
git('commit', '--allow-empty', '-m', 'fix: production issue'); git('commit', '--allow-empty', '-m', 'fix: production issue');
check({ check({
...mr, ...pr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1', branch: 'hotfix/1.6.1',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main', 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. // The working branch must not pass by merging develop instead of rebasing.
git('switch', 'feature/test'); git('switch', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat: keep working'); git('commit', '--allow-empty', '-m', 'feat: keep working');
git('merge', '--no-ff', 'develop', '-m', 'chore: merge develop'); git('merge', '--no-ff', 'develop', '-m', 'chore: merge develop');
check(mr, 1); check(pr, 1);
console.log('CI policy integration checks passed.'); console.log('CI policy integration checks passed.');
} finally { } finally {