diff --git a/.github/workflows/_prepare-app-image.yml b/.github/workflows/_prepare-app-image.yml new file mode 100644 index 0000000..3c5ef26 --- /dev/null +++ b/.github/workflows/_prepare-app-image.yml @@ -0,0 +1,151 @@ +name: Prepare application image + +# Resolves which version of the application the test jobs must run against. +# +# The link is declared as an "Application-PR:" trailer in the description of the test pull +# request. It lives outside the repository content on purpose, so merging a test pull request can +# never leave an active link behind on the default branch. +# +# Normal mode (no explicit link to an application pull request) does nothing at all: the +# application repository is not read, no image is built and no temporary image is created. The +# test jobs then keep using the image declared by the profile manifest. +# +# Pull request mode resolves the HEAD commit of the linked application pull request, reuses the +# already published temporary image for that commit when it exists, and otherwise builds and +# pushes it. + +on: + workflow_call: + inputs: + application_repository: + description: Application repository as owner/name; overrides the pull request description + required: false + type: string + default: "" + application_pull_request: + description: Application pull request number; overrides the pull request description + required: false + type: string + default: "" + pull_request_body: + description: >- + Description of the test pull request. It is scanned for the "Application-PR:" trailer + that declares which application pull request to test. Empty outside a pull request, + which is exactly why a link can never leak into the default branch. + required: false + type: string + default: "" + outputs: + app_source: + description: docker-image or pull-request + value: ${{ jobs.prepare.outputs.app_source }} + app_repository: + description: Resolved application repository, empty in normal mode + value: ${{ jobs.prepare.outputs.app_repository }} + app_pr: + description: Resolved application pull request number, empty in normal mode + value: ${{ jobs.prepare.outputs.app_pr }} + app_sha: + description: HEAD commit of the application pull request, empty in normal mode + value: ${{ jobs.prepare.outputs.app_sha }} + app_image: + description: Temporary application image, empty in normal mode + value: ${{ jobs.prepare.outputs.app_image }} + secrets: + application_repository_token: + description: >- + Token able to read the application repository. Only required when that repository is + private; the built-in GITHUB_TOKEN is used otherwise. + required: false + +jobs: + prepare: + name: Resolve application source + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + packages: write + outputs: + # The link step only reads the declaration; the image step is what knows whether the + # linked pull request is still open, so its resolution wins when it ran. + app_source: ${{ steps.image.outputs.app_source || steps.link.outputs.app_source }} + app_repository: ${{ steps.link.outputs.app_repository }} + app_pr: ${{ steps.link.outputs.app_pr }} + app_sha: ${{ steps.image.outputs.app_sha }} + app_image: ${{ steps.image.outputs.app_image }} + env: + APP_REPOSITORY: ${{ inputs.application_repository }} + APP_PR: ${{ inputs.application_pull_request }} + # Untrusted text: only ever bound to an environment variable, never interpolated into a + # shell command. + APP_LINK_BODY: ${{ inputs.pull_request_body }} + APP_IMAGE_REPOSITORY: ghcr.io/${{ github.repository }}/semaphore-ci + steps: + - name: Checkout tests + uses: actions/checkout@v7 + + - name: Resolve application link + id: link + run: scripts/app-source.sh link + + - name: Report normal mode + if: steps.link.outputs.app_source != 'pull-request' + run: | + printf 'Application source: Docker image\n' + printf 'Application image: profile manifest default\n' + printf 'Application build: skipped\n' + + - name: Set up Buildx + if: steps.link.outputs.app_source == 'pull-request' + uses: docker/setup-buildx-action@v3 + + - name: Log in to the temporary image registry + if: steps.link.outputs.app_source == 'pull-request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve, reuse or build the application image + id: image + if: steps.link.outputs.app_source == 'pull-request' + env: + GH_TOKEN: ${{ secrets.application_repository_token || secrets.GITHUB_TOKEN }} + run: scripts/app-source.sh ensure + + - name: Summary + env: + APP_SOURCE: ${{ steps.image.outputs.app_source || steps.link.outputs.app_source }} + APP_PR_STATE: ${{ steps.image.outputs.app_pr_state }} + APP_LINK_SOURCE: ${{ steps.link.outputs.app_link_source }} + APP_REPOSITORY: ${{ steps.link.outputs.app_repository }} + APP_PR: ${{ steps.link.outputs.app_pr }} + APP_SHA: ${{ steps.image.outputs.app_sha }} + APP_IMAGE: ${{ steps.image.outputs.app_image }} + APP_BUILD_PERFORMED: ${{ steps.image.outputs.app_build_performed }} + run: | + { + if [ "$APP_SOURCE" = "pull-request" ]; then + printf '### Application source: Pull Request\n\n' + printf -- '- repository: `%s`\n' "$APP_REPOSITORY" + printf -- '- pull request: #%s\n' "$APP_PR" + printf -- '- SHA: `%s`\n' "$APP_SHA" + printf -- '- image: `%s`\n' "$APP_IMAGE" + if [ "$APP_BUILD_PERFORMED" = "true" ]; then + printf -- '- build: performed\n' + else + printf -- '- build: skipped, the image for this commit already existed\n' + fi + else + printf '### Application source: Docker image\n\n' + if [ -n "$APP_PR_STATE" ] && [ "$APP_PR_STATE" != "open" ]; then + printf -- '- application pull request #%s is **%s**, so there is no version left to test\n' "$APP_PR" "$APP_PR_STATE" + printf -- '- the run fell back to normal mode\n' + printf -- '- remove the `Application-PR:` line from this pull request description to silence this\n' + fi + printf -- '- the application repository was not cloned and no image was built\n' + printf -- '- the profile manifest image is used, as before\n' + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/application-pr.yml b/.github/workflows/application-pr.yml new file mode 100644 index 0000000..7a8e4e3 --- /dev/null +++ b/.github/workflows/application-pr.yml @@ -0,0 +1,128 @@ +name: Application PR trigger + +# Runs the integration tests of every test pull request whose description declares an +# "Application-PR:" trailer pointing at the application pull request named in the event payload. +# +# The application repository sends the event; see docs/application-pr-testing.md for the +# workflow snippet it needs. Only test pull requests whose description declares this exact +# application pull request are started: a change of an arbitrary branch of the application +# repository starts nothing, and the link is never inferred from branch names. + +on: + repository_dispatch: + types: + - application-pr-updated + workflow_dispatch: + inputs: + application_repository: + description: Application repository as owner/name + required: true + type: string + default: semaphoreui/semaphore + application_pull_request: + description: Application pull request number + required: true + type: string + +permissions: + contents: read + +concurrency: + group: application-pr-${{ github.event.client_payload.pull_request || inputs.application_pull_request }} + cancel-in-progress: false + +jobs: + dispatch: + name: Start linked test pull requests + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: write + pull-requests: read + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_APP_REPOSITORY: ${{ github.event.client_payload.repository || inputs.application_repository }} + EVENT_APP_PR: ${{ github.event.client_payload.pull_request || inputs.application_pull_request }} + EVENT_APP_SHA: ${{ github.event.client_payload.sha }} + steps: + - name: Checkout tests + uses: actions/checkout@v7 + + - name: Validate the event payload + run: | + set -eu + case "$EVENT_APP_PR" in + ''|*[!0-9]*) + printf 'Invalid application pull request in the event payload: %s\n' "$EVENT_APP_PR" >&2 + exit 1 + ;; + esac + case "$EVENT_APP_REPOSITORY" in + */*) ;; + *) + printf 'Invalid application repository in the event payload: %s\n' "$EVENT_APP_REPOSITORY" >&2 + exit 1 + ;; + esac + printf 'Application repository: %s\n' "$EVENT_APP_REPOSITORY" + printf 'Application PR: #%s\n' "$EVENT_APP_PR" + [ -z "$EVENT_APP_SHA" ] || printf 'Application SHA: %s\n' "$EVENT_APP_SHA" + + - name: Start every linked test pull request + run: | + set -eu + work_dir=$(mktemp -d) + started=0 + inspected=0 + + # Every open test pull request is listed with its description, which is where the + # Application-PR trailer lives. + gh pr list --state open --limit 100 \ + --json number,headRefName,isCrossRepository \ + --jq '.[] | [.number, .headRefName, (.isCrossRepository | tostring)] | @tsv' \ + > "$work_dir/pulls.tsv" + + while IFS=$'\t' read -r pr_number head_ref cross_repository; do + [ -n "$pr_number" ] || continue + inspected=$((inspected + 1)) + + # A fork branch cannot be used as a workflow_dispatch ref; such pull requests keep + # running on their own pull_request events instead. + if [ "$cross_repository" = "true" ]; then + printf 'Test PR #%s: skipped, the head branch lives in a fork\n' "$pr_number" + continue + fi + + body_file="$work_dir/body-$pr_number.md" + gh pr view "$pr_number" --json body --jq '.body // ""' > "$body_file" + + if ! link=$(APP_REPOSITORY= APP_PR= APP_LINK_BODY_FILE="$body_file" \ + scripts/app-source.sh link 2>"$work_dir/link-error"); then + printf 'Test PR #%s: skipped, the Application-PR declaration is invalid\n' "$pr_number" + sed 's/^/ /' "$work_dir/link-error" || true + continue + fi + + linked_pr=$(printf '%s\n' "$link" | sed -n 's/^APP_PR=//p') + linked_repository=$(printf '%s\n' "$link" | sed -n 's/^APP_REPOSITORY=//p') + if [ "$linked_pr" != "$EVENT_APP_PR" ] || [ "$linked_repository" != "$EVENT_APP_REPOSITORY" ]; then + printf 'Test PR #%s: not linked to %s#%s\n' "$pr_number" "$EVENT_APP_REPOSITORY" "$EVENT_APP_PR" + continue + fi + + printf 'Test PR #%s: linked to %s#%s, starting CI on %s\n' \ + "$pr_number" "$EVENT_APP_REPOSITORY" "$EVENT_APP_PR" "$head_ref" + gh workflow run ci.yml --ref "$head_ref" \ + --field "application_repository=$EVENT_APP_REPOSITORY" \ + --field "application_pull_request=$EVENT_APP_PR" + started=$((started + 1)) + done < "$work_dir/pulls.tsv" + + rm -rf "$work_dir" + printf 'Inspected %s open test pull requests, started %s runs.\n' "$inspected" "$started" + { + printf '### Application PR %s#%s\n\n' "$EVENT_APP_REPOSITORY" "$EVENT_APP_PR" + printf -- '- open test pull requests inspected: %s\n' "$inspected" + printf -- '- linked test pull requests started: %s\n' "$started" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a63a0f..4b91c6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,18 +2,54 @@ name: CI on: pull_request: + # The application pull request is declared in the description, so editing the description + # must be able to start a run. A re-run would not do: it replays the original event payload, + # which still carries the description the pull request was opened with. The concurrency group + # below cancels the superseded run, so an edit costs at most one restart. + types: + - opened + - synchronize + - reopened + - edited push: branches: - main + workflow_dispatch: + inputs: + application_repository: + description: Application repository as owner/name; overrides the pull request description + required: false + type: string + default: "" + application_pull_request: + description: Application pull request number; overrides the pull request description + required: false + type: string + default: "" permissions: contents: read concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.ref }}-${{ inputs.application_pull_request || 'default' }} cancel-in-progress: true jobs: + app-image: + name: Application source + permissions: + contents: read + packages: write + uses: ./.github/workflows/_prepare-app-image.yml + with: + application_repository: ${{ inputs.application_repository || '' }} + application_pull_request: ${{ inputs.application_pull_request || '' }} + # Empty for a push to main, a scheduled run or a manual run, so those keep behaving + # exactly as before. + pull_request_body: ${{ github.event.pull_request.body }} + secrets: + application_repository_token: ${{ secrets.APPLICATION_REPOSITORY_TOKEN }} + quality: name: Framework quality gate runs-on: ubuntu-latest @@ -60,15 +96,32 @@ jobs: core-sqlite: name: Core API + UI · SQLite - needs: quality + needs: + - quality + - app-image runs-on: ubuntu-latest timeout-minutes: 35 + permissions: + contents: read + packages: read env: PROFILE: core-sqlite-local + APP_IMAGE: ${{ needs.app-image.outputs.app_image }} + APP_REPOSITORY: ${{ needs.app-image.outputs.app_repository }} + APP_PR: ${{ needs.app-image.outputs.app_pr }} + APP_SHA: ${{ needs.app-image.outputs.app_sha }} steps: - name: Checkout uses: actions/checkout@v7 + - name: Log in to the temporary image registry + if: needs.app-image.outputs.app_source == 'pull-request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Java 21 uses: actions/setup-java@v5 with: @@ -136,6 +189,7 @@ jobs: name: Build Allure report if: ${{ always() }} needs: + - app-image - quality - core-sqlite permissions: diff --git a/.github/workflows/cleanup-pr-images.yml b/.github/workflows/cleanup-pr-images.yml new file mode 100644 index 0000000..9f42323 --- /dev/null +++ b/.github/workflows/cleanup-pr-images.yml @@ -0,0 +1,135 @@ +name: Cleanup temporary application images + +# Removes temporary application images built for application pull requests that are closed or +# merged. Only the semaphore-ci package of this test repository is touched; release images of +# semaphoreui/semaphore live in a different registry namespace and are never inspected here. +# +# Deleting a package version needs a token with delete:packages, which the built-in GITHUB_TOKEN +# does not have. Store one as the GHCR_CLEANUP_TOKEN secret. Without it the workflow only +# reports what it would delete. + +on: + schedule: + - cron: "0 4 * * *" + workflow_dispatch: + inputs: + dry_run: + description: Only report the deletion candidates + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: cleanup-pr-images + cancel-in-progress: false + +jobs: + cleanup: + name: Delete images of closed application pull requests + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: read + env: + GH_TOKEN: ${{ secrets.GHCR_CLEANUP_TOKEN || secrets.GITHUB_TOKEN }} + PACKAGE_OWNER: ${{ github.repository_owner }} + PACKAGE_NAME: ${{ github.event.repository.name }}/semaphore-ci + DEFAULT_APP_REPOSITORY: ${{ vars.APPLICATION_REPOSITORY || 'semaphoreui/semaphore' }} + DRY_RUN: ${{ inputs.dry_run || !secrets.GHCR_CLEANUP_TOKEN }} + # Grace period after the application pull request was closed or merged, so that a run + # started just before the merge can still pull its image. + RETENTION_HOURS: "24" + steps: + - name: Delete stale temporary images + run: | + set -eu + work_dir=$(mktemp -d) + + owner_type=organization + if [ "$(gh api "users/$PACKAGE_OWNER" --jq '.type')" = "User" ]; then + owner_type=user + fi + case "$owner_type" in + organization) versions_path="orgs/$PACKAGE_OWNER/packages/container/$(printf '%s' "$PACKAGE_NAME" | sed 's|/|%2F|g')/versions" ;; + user) versions_path="users/$PACKAGE_OWNER/packages/container/$(printf '%s' "$PACKAGE_NAME" | sed 's|/|%2F|g')/versions" ;; + esac + + if ! gh api --paginate "$versions_path" \ + --jq '.[] | [(.id | tostring), (.metadata.container.tags | join(","))] | @tsv' \ + > "$work_dir/versions.tsv" 2>"$work_dir/error"; then + if grep -qi 'not found' "$work_dir/error"; then + printf 'No temporary image package exists yet; nothing to clean up.\n' + exit 0 + fi + cat "$work_dir/error" >&2 + exit 1 + fi + + now=$(date -u +%s) + candidates=0 + deleted=0 + kept=0 + + while IFS=$'\t' read -r version_id tags; do + [ -n "$version_id" ] || continue + + # Only tags produced by scripts/app-source.sh are considered: ci-pr--. + pr_number=$(printf '%s\n' "$tags" | tr ',' '\n' | sed -n 's/^ci-pr-\([0-9][0-9]*\)-[0-9a-f]\{40\}$/\1/p' | sed -n '1p') + if [ -z "$pr_number" ]; then + kept=$((kept + 1)) + continue + fi + + pr_state=$(gh api "repos/$DEFAULT_APP_REPOSITORY/pulls/$pr_number" \ + --jq '[.state, (.closed_at // "")] | @tsv' 2>/dev/null || true) + if [ -z "$pr_state" ]; then + printf 'Version %s (%s): application PR #%s is unreachable, keeping the image\n' \ + "$version_id" "$tags" "$pr_number" + kept=$((kept + 1)) + continue + fi + + state=$(printf '%s' "$pr_state" | cut -f1) + closed_at=$(printf '%s' "$pr_state" | cut -f2) + if [ "$state" != "closed" ] || [ -z "$closed_at" ]; then + kept=$((kept + 1)) + continue + fi + + closed_epoch=$(date -u -d "$closed_at" +%s) + age_hours=$(( (now - closed_epoch) / 3600 )) + if [ "$age_hours" -lt "$RETENTION_HOURS" ]; then + printf 'Version %s (%s): application PR #%s closed %sh ago, within the retention window\n' \ + "$version_id" "$tags" "$pr_number" "$age_hours" + kept=$((kept + 1)) + continue + fi + + candidates=$((candidates + 1)) + if [ "$DRY_RUN" = "true" ]; then + printf 'Version %s (%s): would delete, application PR #%s closed %sh ago\n' \ + "$version_id" "$tags" "$pr_number" "$age_hours" + continue + fi + + printf 'Version %s (%s): deleting, application PR #%s closed %sh ago\n' \ + "$version_id" "$tags" "$pr_number" "$age_hours" + gh api --method DELETE "$versions_path/$version_id" + deleted=$((deleted + 1)) + done < "$work_dir/versions.tsv" + + rm -rf "$work_dir" + { + printf '### Temporary application images\n\n' + printf -- '- package: `ghcr.io/%s/%s`\n' "$PACKAGE_OWNER" "$PACKAGE_NAME" + printf -- '- deletion candidates: %s\n' "$candidates" + printf -- '- deleted: %s\n' "$deleted" + printf -- '- kept: %s\n' "$kept" + if [ "$DRY_RUN" = "true" ]; then + printf -- '- dry run: no version was deleted (set the GHCR_CLEANUP_TOKEN secret to enable deletion)\n' + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index ecf205f..3125f45 100644 --- a/README.md +++ b/README.md @@ -211,10 +211,36 @@ GitHub Actions разделены по стоимости и назначени - `CI` запускается для каждого pull request и push в `main`: сначала выполняет framework quality gate, затем core API suite и короткий Chromium UI smoke на `core-sqlite-local`; - `Configuration matrix` ежедневно в `01:30 UTC` и вручную проверяет PostgreSQL, MySQL, MariaDB, production-like PostgreSQL с persistent runner, SSH, приватный HTTPS Git, прямой и HTTPS/subpath OIDC, LDAPS, TOTP и ротацию database encryption keyring; -- `Release upgrade` еженедельно по воскресеньям в `03:30 UTC` и вручную проверяет обновление `v2.19.8 → v2.19.12` на SQLite и PostgreSQL. +- `Release upgrade` еженедельно по воскресеньям в `03:30 UTC` и вручную проверяет обновление `v2.19.8 → v2.19.12` на SQLite и PostgreSQL; +- `Application PR trigger` принимает `repository_dispatch` из основного репозитория и запускает CI для тестовых PR, явно связанных с изменившимся PR приложения; +- `Cleanup temporary application images` ежедневно в `04:00 UTC` удаляет временные images закрытых и смерженных PR приложения. Matrix jobs используют отдельные GitHub-hosted runners и выполняются параллельно с `fail-fast: false`. JUnit, HTML-отчёты, Allure results и диагностика контейнеров при падении сохраняются как artifacts. Upgrade workflow не входит в PR gate; зелёный job должен означать и сохранность данных, и полную финализацию task output. +### Источник тестов и источник приложения + +Две настройки независимы. `TEST_REPOSITORY` / `TEST_BRANCH` (`git.fixtures.repository` / +`git.fixtures.branch`) по-прежнему определяют только то, какие фикстуры и тесты использовать. +Отдельная группа `APP_REPOSITORY` / `APP_PR` определяет, какую версию приложения тестировать. + +Если application PR не задан, поведение не меняется: основной репозиторий не клонируется, +приложение не собирается, временный Docker image не создаётся, используется image из манифеста +профиля. Чтобы прогнать тесты против конкретного PR основного репозитория, достаточно добавить +одну строку в **описание тестового PR**: + +```text +Application-PR: semaphoreui/semaphore#123 +``` + +Описание PR выбрано намеренно: в отличие от файла в репозитории оно не попадает в `main` при +merge, поэтому забытая связь не может повлиять на обычные прогоны. + +CI определяет HEAD SHA этого PR, переиспользует уже опубликованный +`ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-` и собирает приложение только +тогда, когда image для этого commit ещё не существует. Изменение только тестов повторной сборки +не вызывает. Полное описание, включая автозапуск, авторизацию и очистку временных images, — +в [`docs/application-pr-testing.md`](docs/application-pr-testing.md). + При ручном запуске `Configuration matrix` можно включить inputs `include_schedule_investigation` и/или `include_shell_output_investigation`. Тогда к матрице только для этого run добавятся соответствующие известные красные defect-профили, чтобы подтвердить проблему на Linux и собрать diff --git a/docs/application-pr-testing.md b/docs/application-pr-testing.md new file mode 100644 index 0000000..51783f5 --- /dev/null +++ b/docs/application-pr-testing.md @@ -0,0 +1,286 @@ +# Тестирование Pull Request основного репозитория + +Тестовый и основной репозитории остаются независимыми: submodule не используются, тесты не +переносятся в основной репозиторий, а приложение — в тестовый. Разделены две независимые +настройки. + +| Что определяет | Настройка | Где задаётся | +| --- | --- | --- | +| **Какие тесты запускать** | `TEST_REPOSITORY` / `TEST_BRANCH` (`git.fixtures.repository` / `git.fixtures.branch`) | [MainConfig.java](../src/main/java/io/bookwright/config/MainConfig.java), stand properties, `-D`-параметры | +| **Какую версию приложения тестировать** | строка `Application-PR:` в описании тестового PR | описание PR; `APP_REPOSITORY` / `APP_PR` как служебный механизм CI | + +Семантика `TEST_REPOSITORY` / `TEST_BRANCH` не изменилась. + +## Два режима + +### Обычный режим (по умолчанию) + +В описании тестового PR нет строки `Application-PR:`. Основной репозиторий не клонируется, +приложение не собирается, временный Docker image не создаётся. Используется image из манифеста профиля +(`test-environment/profiles//profile.yaml`, ключ `semaphore_image`) — ровно как раньше. + +```text +clone tests → pull semaphore_image → start application → run tests +``` + +Никаких дополнительных действий при обычной разработке тестов не требуется. + +### PR-режим + +Тестовый прогон явно связан с Pull Request основного репозитория. Pipeline определяет HEAD SHA +этого PR, вычисляет тег временного image, переиспользует его при наличии и собирает только при +отсутствии. + +```text +APP_PR → HEAD SHA → image exists? → (нет: checkout PR → build → push) → start application → run tests +``` + +## Связывание тестового PR с PR приложения + +Связь всегда **явная**. Она никогда не выводится из названия ветки, слова `feature`, совпадения +названий веток или самого факта изменения тестовой ветки. + +Единственное место, где разработчик её задаёт, — **описание тестового PR**. Достаточно добавить +одну строку: + +```text +Application-PR: semaphoreui/semaphore#123 +``` + +Всё. Дальше CI делает остальное. + +### Почему именно описание PR + +Описание PR не является частью содержимого репозитория и **не попадает в `main` при merge**. +Поэтому забытая связь физически не может превратить обычный прогон `main` в сборку давно +закрытого PR приложения, а ветки, срезанные от `main`, ничего не наследуют. Файл в репозитории +такой гарантии не даёт — он мержится вместе с PR. + +### Принимаемые формы + +| Запись | Смысл | +| --- | --- | +| `Application-PR: semaphoreui/semaphore#123` | репозиторий и номер явно | +| `Application-PR: #123` | репозиторий по умолчанию — `semaphoreui/semaphore` | +| `Application-PR: 123` | то же самое | +| `Application-PR: https://github.com/semaphoreui/semaphore/pull/123` | ссылка целиком, можно с `/files` | + +Ключ нечувствителен к регистру и допускает `Application PR:` и `Application_PR:`. Строка должна +начинать строку описания — упоминание `Application-PR:` внутри предложения связью не считается. +Текст внутри HTML-комментариев игнорируется, поэтому шаблон PR может содержать +закомментированный пример. + +Две и более строки `Application-PR:` — ошибка pipeline, а не молчаливый выбор одной из них. + +Редактирование описания перезапускает CI: `ci.yml` подписан на тип события `edited` вдобавок к +`opened`/`synchronize`/`reopened`. Без этого добавленная после открытия PR строка не подхватилась +бы, а ручной re-run не помог бы — он воспроизводит исходный payload со старым описанием. + +### Что происходит после merge PR приложения + +Пока тестовый PR открыт, его PR приложения может быть смержен. У такого PR не осталось версии +для тестирования: коммиты уже в основной ветке приложения. Pipeline громко пишет причину и +откатывается в обычный режим — берёт image из манифеста профиля, вместо того чтобы навсегда +прибить тесты к устаревшему коммиту. Строку из описания после этого стоит убрать. + +### CI-переменные + +`APP_REPOSITORY` и `APP_PR` — служебный механизм CI, а не способ ручного объявления связи. Через +них workflow автозапуска стартует прогон для ветки, где контекста PR (а значит и описания) нет. +Они имеют приоритет над описанием. Тот же путь доступен вручную: + +```bash +gh workflow run ci.yml --ref feature/BOOK-123 \ + --field application_repository=semaphoreui/semaphore \ + --field application_pull_request=123 +``` + +## Идентификация и изоляция временных images + +Тег временного image содержит номер PR и полный SHA его HEAD commit: + +```text +ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-abc123456789... +``` + +* два разных commit одного PR дают разные images; +* несколько пар application/test PR никогда не делят один image; +* временные images лежат в отдельном namespace GHCR тестового репозитория, поэтому release-теги + `semaphoreui/semaphore` не читаются, не перезаписываются и вообще не затрагиваются. + +Namespace переопределяется переменной `APP_IMAGE_REPOSITORY`, префикс тега — `APP_IMAGE_TAG_PREFIX`. + +## Повторное использование образа + +Перед сборкой проверяется наличие image для вычисленного SHA: + +| Ситуация | Поведение | +| --- | --- | +| Изменился только тестовый PR, SHA приложения прежний | image существует → `pull → test`, сборка не выполняется | +| В application PR появился новый commit | новый тег → `build → push → test` | +| В описании нет `Application-PR:` | ни клонирования, ни сборки, ни временного image | +| Application PR закрыт или смержен | откат в обычный режим, сборки нет | +| Прогон не является тестовым PR | описания нет в контексте, обычный режим | + +## Автоматический запуск + +### При изменении PR приложения + +Workflow [`application-pr.yml`](../.github/workflows/application-pr.yml) принимает событие +`repository_dispatch` типа `application-pr-updated`, читает описания всех открытых тестовых PR и +находит **те, что явно объявили связь с этим PR приложения**, после чего запускает для них CI. Тестовые PR без связи или связанные с +другим application PR не запускаются, изменение произвольной ветки основного репозитория не +запускает ничего. + +Чтобы включить автозапуск, в основной репозиторий `semaphoreui/semaphore` нужно один раз добавить +`.github/workflows/notify-integration-tests.yml`: + +```yaml +name: Notify integration tests + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Notify the test repository + env: + GH_TOKEN: ${{ secrets.INTEGRATION_TESTS_DISPATCH_TOKEN }} + run: | + gh api repos/semaphoreui/integration-tests/dispatches \ + --field event_type=application-pr-updated \ + --field 'client_payload[repository]=${{ github.repository }}' \ + --field 'client_payload[pull_request]=${{ github.event.pull_request.number }}' \ + --field 'client_payload[sha]=${{ github.event.pull_request.head.sha }}' +``` + +`INTEGRATION_TESTS_DISPATCH_TOKEN` — токен с правом `contents: write` на тестовый репозиторий +(fine-grained PAT или GitHub App installation token). Токен хранится только в secrets и не +передаётся через параметры командной строки. + +Тот же workflow запускается вручную: + +```bash +gh workflow run application-pr.yml \ + --field application_repository=semaphoreui/semaphore \ + --field application_pull_request=123 +``` + +**Ограничение fork**: у тестового PR из fork `GITHUB_TOKEN` доступен только на чтение, поэтому +такой PR нельзя ни запустить через `workflow_dispatch` (его ветки нет в тестовом репозитории), ни +использовать для push временного image. Такие PR продолжают проверяться собственным событием +`pull_request` в обычном режиме; в логе `Application PR trigger` они отмечаются явно. Для +PR-режима ветку тестового PR нужно держать в самом тестовом репозитории. + +### При изменении тестового PR + +Обычное событие `pull_request` workflow [`ci.yml`](../.github/workflows/ci.yml). Оно передаёт +описание PR в job `Application source`, который резолвит связь, переиспользует существующий image и запускает тесты. Если +SHA приложения не изменился, сборка не выполняется. + +## Авторизация + +| Секрет / переменная | Назначение | Обязателен | +| --- | --- | --- | +| `GITHUB_TOKEN` (встроенный) | чтение публичного основного репозитория, push временного image в GHCR тестового репозитория | да, выдаётся автоматически | +| `APPLICATION_REPOSITORY_TOKEN` | чтение и checkout основного репозитория, если он private | только для private | +| `GHCR_CLEANUP_TOKEN` | удаление временных images (`delete:packages`) | только для очистки | +| `vars.APPLICATION_REPOSITORY` | основной репозиторий для очистки, по умолчанию `semaphoreui/semaphore` | нет | + +Токены передаются только через переменные окружения и secrets. При checkout PR используется +git credential helper, читающий токен из окружения, поэтому токен не попадает ни в командную +строку, ни в репозиторий. + +## Очистка временных images + +Workflow [`cleanup-pr-images.yml`](../.github/workflows/cleanup-pr-images.yml) выполняется +ежедневно и удаляет версии пакета `semaphore-ci`, чей тег соответствует закрытому или +смерженному application PR, спустя окно ожидания (`RETENTION_HOURS`, по умолчанию 24 часа). +Обрабатываются только теги вида `ci-pr--` в namespace тестового репозитория — +release images не затрагиваются. Без секрета `GHCR_CLEANUP_TOKEN` workflow работает в режиме +dry-run и только сообщает кандидатов на удаление. + +## Локальный запуск + +Резолв без каких-либо побочных эффектов: + +```bash +APP_LINK_BODY='Application-PR: semaphoreui/semaphore#123' scripts/app-source.sh resolve +``` + +Описание можно передать и файлом — `APP_LINK_BODY_FILE=path`. Для локальных экспериментов проще +использовать служебные `APP_PR` / `APP_REPOSITORY`: + +```bash +APP_PR=123 scripts/app-source.sh resolve +``` + +Сборка локального образа без публикации и прогон профиля против него: + +```bash +export APP_PR=123 +export APP_IMAGE_REPOSITORY=local/semaphore-ci +export APP_BUILD_PUSH=false +eval "$(scripts/app-source.sh ensure | grep '^APP_')" + +test-environment/profile up core-sqlite-local +test-environment/profile test core-sqlite-local +``` + +`test-environment/profile` берёт image из `APP_IMAGE`, если переменная задана, и из манифеста +профиля в противном случае. Полезные переменные сборки: `APP_BUILD_PLATFORM` (по умолчанию +`linux/amd64`), `APP_DOCKERFILE` (по умолчанию `deployment/docker/server/Dockerfile`), +`APP_BUILD_PUSH`. + +## Логирование + +Обычный режим: + +```text +Application source: Docker image +Application image: semaphoreui/semaphore:v2.19.12 +Application build: skipped +``` + +PR-режим с переиспользованием: + +```text +Application source: Pull Request +Application repository: semaphoreui/semaphore +Application PR: #123 +Application SHA: abc123456789... +Application image: ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-abc123456789... +Application image already exists +Application build: skipped +``` + +PR-режим со сборкой: + +```text +Application image not found +Building application... +Application build: completed +``` + +Режим также попадает в Allure environment: `application.source`, `application.repository`, +`application.pull.request`, `semaphore.image`, `semaphore.source.commit`. + +## Обработка ошибок + +| Ситуация | Поведение | +| --- | --- | +| PR приложения не существует | `Application PR #123 not found in `, pipeline падает | +| Нет доступа к репозиторию | `Unable to access application repository `, pipeline падает | +| Не удалось определить SHA | `Unable to resolve the HEAD SHA of application PR #123`, pipeline падает | +| Две строки `Application-PR:` в описании | pipeline падает, выбор одной из них не делается | +| `Application-PR:` не похож на ссылку на PR | pipeline падает с указанием исходного значения | +| PR получил новый commit во время сборки | сборка прерывается с явным сообщением о рассинхронизации | +| Не удалось собрать image | pipeline падает, Docker build logs остаются в выводе шага | +| Не удалось push-нуть image | pipeline падает после проверки, что image действительно отсутствует в registry | +| Image недоступен для pull | тег считается отсутствующим, выполняется сборка и push | diff --git a/scripts/app-source.sh b/scripts/app-source.sh new file mode 100755 index 0000000..8f6faf4 --- /dev/null +++ b/scripts/app-source.sh @@ -0,0 +1,475 @@ +#!/bin/sh +# +# Resolves which version of the Semaphore application the tests must run against. +# +# Two independent settings exist in this repository: +# +# * the test source - git.fixtures.repository / git.fixtures.branch (TEST_REPOSITORY / +# TEST_BRANCH). It selects which fixtures and test cases are used and is untouched here. +# * the application source - resolved by this script. By default the profile manifest image +# is used and the application repository is never cloned or built. When a test run is +# explicitly linked to a pull request of the application repository, the image is built +# from that pull request HEAD commit and reused across runs. +# +# Usage: +# scripts/app-source.sh link Resolve only the explicit link (no GitHub API, no registry). +# scripts/app-source.sh resolve Resolve the application source and print a human readable +# report plus KEY=value lines on stdout. +# scripts/app-source.sh env Print only the KEY=value lines. +# scripts/app-source.sh ensure Resolve, then build and push the application image when it +# does not exist yet. Prints the same report. +# +# The link is declared in the description of the test pull request, as a single trailer line: +# +# Application-PR: semaphoreui/semaphore#123 +# +# Accepted equally: "#123", "123" and the full pull request URL. The repository defaults to +# semaphoreui/semaphore. The key is case insensitive and also accepts "Application PR:" and +# "Application_PR:". Text inside HTML comments is ignored, so a pull request template may carry +# a commented-out example. +# +# The description is deliberately the only place a developer declares the link: unlike a file in +# the repository it never reaches the default branch when the test pull request is merged, so a +# forgotten link cannot turn the normal pipeline into a build of some old application pull +# request. The description reaches this script through APP_LINK_BODY or APP_LINK_BODY_FILE. +# +# APP_PR / APP_REPOSITORY stay available as CI plumbing: the application pull request trigger +# uses them to start a run for a branch, where no pull request description is in context. They +# take precedence over the description. +# +# Without a declared pull request the script stays in normal mode. It never infers the +# application pull request from branch names. A closed or merged application pull request also +# falls back to normal mode, because it has no version left to test. + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repository_dir=$(CDPATH= cd -- "$script_dir/.." && pwd) + +DEFAULT_APP_REPOSITORY=semaphoreui/semaphore +DEFAULT_IMAGE_TAG_PREFIX=ci-pr +DEFAULT_APP_DOCKERFILE=deployment/docker/server/Dockerfile + +fail() { + printf 'app-source: %s\n' "$1" >&2 + exit 1 +} + +usage() { + sed -n '3,42p' "$0" | sed 's/^#\{0,1\} \{0,1\}//' +} + +# semaphoreui/semaphore, https://github.com/semaphoreui/semaphore.git and +# git@github.com:semaphoreui/semaphore.git all normalise to semaphoreui/semaphore. +normalise_repository() { + value=$1 + value=${value%.git} + case "$value" in + http://*|https://*) + value=${value#*://} + value=${value#*/} + ;; + *@*:*) + value=${value#*:} + ;; + esac + value=${value#/} + value=${value%/} + + case "$value" in + ''|*/*/*|*[!A-Za-z0-9._/-]*) fail "invalid application repository: $1" ;; + */*) ;; + *) fail "invalid application repository: $1 (expected owner/name)" ;; + esac + printf '%s' "$value" +} + +# Extracts every "Application-PR:" trailer from the pull request description. Text inside HTML +# comments is stripped first, so the commented-out example of a pull request template is not +# mistaken for a real declaration. +BODY_LINK_AWK=' +{ + line = $0 + sub(/\r$/, "", line) + visible = "" + rest = line + while (rest != "") { + if (in_comment) { + position = index(rest, "-->") + if (position == 0) { rest = ""; break } + rest = substr(rest, position + 3) + in_comment = 0 + } else { + position = index(rest, "\n" + "Ordinary test change.\n" + ) + + values = self.parse(self.run_script(environment={"APP_LINK_BODY": body}).stdout) + + self.assertEqual("docker-image", values["APP_SOURCE"]) + self.assertEqual("", values["APP_PR"]) + + def test_the_trailer_must_start_a_line(self): + self.stub_gh() + self.stub_docker() + body = "We considered the Application-PR: semaphoreui/semaphore#999 approach but did not." + + values = self.parse(self.run_script(environment={"APP_LINK_BODY": body}).stdout) + + self.assertEqual("docker-image", values["APP_SOURCE"]) + + def test_an_explicit_app_image_is_preserved(self): + self.stub_gh() + self.stub_docker() + + values = self.parse( + self.run_script(environment={"APP_IMAGE": "semaphoreui/semaphore:v2.19.12"}).stdout + ) + + self.assertEqual("docker-image", values["APP_SOURCE"]) + self.assertEqual("semaphoreui/semaphore:v2.19.12", values["APP_IMAGE"]) + + +class DescriptionLinkTest(AppSourceTestCase): + def test_owner_name_and_number(self): + self.stub_gh() + self.stub_docker() + body = "Covers the new runner isolation.\n\nApplication-PR: semaphoreui/semaphore#123\n" + + values = self.parse( + self.run_script( + environment={ + "APP_LINK_BODY": body, + "APP_IMAGE_REPOSITORY": "ghcr.io/semaphoreui/integration-tests/semaphore-ci", + } + ).stdout + ) + + self.assertEqual("pull-request", values["APP_SOURCE"]) + self.assertEqual("semaphoreui/semaphore", values["APP_REPOSITORY"]) + self.assertEqual("123", values["APP_PR"]) + self.assertEqual("pull-request-body", values["APP_LINK_SOURCE"]) + self.assertEqual( + f"ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-{HEAD_SHA}", + values["APP_IMAGE"], + ) + + def test_every_accepted_reference_form(self): + self.stub_gh() + self.stub_docker() + + for reference in ( + "semaphoreui/semaphore#123", + "#123", + "123", + "https://github.com/semaphoreui/semaphore/pull/123", + "https://github.com/semaphoreui/semaphore/pull/123/files", + ): + with self.subTest(reference=reference): + values = self.parse( + self.run_script( + environment={"APP_LINK_BODY": f"Application-PR: {reference}"} + ).stdout + ) + self.assertEqual("pull-request", values["APP_SOURCE"]) + self.assertEqual("123", values["APP_PR"]) + self.assertEqual("semaphoreui/semaphore", values["APP_REPOSITORY"]) + + def test_the_key_is_case_and_separator_insensitive(self): + self.stub_gh() + self.stub_docker() + + for key in ("Application-PR", "application pr", "APPLICATION_PR", " Application-Pr"): + with self.subTest(key=key): + values = self.parse( + self.run_script(environment={"APP_LINK_BODY": f"{key}: #123"}).stdout + ) + self.assertEqual("123", values["APP_PR"]) + + def test_a_windows_style_description_is_accepted(self): + self.stub_gh() + self.stub_docker() + + values = self.parse( + self.run_script( + environment={"APP_LINK_BODY": "Summary\r\n\r\nApplication-PR: #123\r\n"} + ).stdout + ) + + self.assertEqual("123", values["APP_PR"]) + + def test_the_description_can_be_supplied_as_a_file(self): + self.stub_gh() + self.stub_docker() + body = self.body_file("Application-PR: semaphoreui/semaphore#123\n") + + values = self.parse( + self.run_script(environment={"APP_LINK_BODY_FILE": str(body)}).stdout + ) + + self.assertEqual("123", values["APP_PR"]) + + def test_ci_inputs_win_over_the_description(self): + self.stub_gh() + self.stub_docker() + + values = self.parse( + self.run_script( + environment={"APP_LINK_BODY": "Application-PR: #111", "APP_PR": "222"} + ).stdout + ) + + self.assertEqual("222", values["APP_PR"]) + self.assertEqual("ci-input", values["APP_LINK_SOURCE"]) + + def test_link_action_resolves_without_gh_or_docker(self): + values = self.parse( + self.run_script("link", environment={"APP_LINK_BODY": "Application-PR: #123"}).stdout + ) + + self.assertEqual("pull-request", values["APP_SOURCE"]) + self.assertEqual("123", values["APP_PR"]) + self.assertEqual("", values["APP_SHA"]) + self.assertEqual("", values["APP_IMAGE"]) + + +class ImageReuseTest(AppSourceTestCase): + def test_an_existing_image_is_reused_without_building(self): + self.stub_gh() + self.stub_docker(manifest_status=0) + + result = self.run_script("ensure", environment={"APP_PR": "123"}) + values = self.parse(result.stdout) + + self.assertEqual("true", values["APP_IMAGE_EXISTS"]) + self.assertEqual("false", values["APP_BUILD_REQUIRED"]) + self.assertEqual("false", values["APP_BUILD_PERFORMED"]) + self.assertIn("Application image already exists", result.stdout) + self.assertIn("Application build: skipped", result.stdout) + + def test_a_missing_image_requests_a_build(self): + self.stub_gh() + self.stub_docker(manifest_status=1) + + result = self.run_script("resolve", environment={"APP_PR": "123"}) + values = self.parse(result.stdout) + + self.assertEqual("false", values["APP_IMAGE_EXISTS"]) + self.assertEqual("true", values["APP_BUILD_REQUIRED"]) + self.assertIn("Application image not found", result.stdout) + self.assertIn("Building application...", result.stdout) + + def test_the_registry_decides_when_the_image_is_pushed(self): + self.stub_gh() + self.stub_docker(manifest_status=1, local_status=0) + + values = self.parse(self.run_script(environment={"APP_PR": "123"}).stdout) + + self.assertEqual("false", values["APP_IMAGE_EXISTS"]) + self.assertEqual("true", values["APP_BUILD_REQUIRED"]) + + def test_the_local_store_decides_when_the_image_is_not_pushed(self): + self.stub_gh() + self.stub_docker(manifest_status=1, local_status=0) + + values = self.parse( + self.run_script(environment={"APP_PR": "123", "APP_BUILD_PUSH": "false"}).stdout + ) + + self.assertEqual("true", values["APP_IMAGE_EXISTS"]) + self.assertEqual("false", values["APP_BUILD_REQUIRED"]) + + def test_a_new_commit_of_the_same_pull_request_yields_a_new_image(self): + other_sha = "abc123456789abc123456789abc123456789abcd" + self.stub_docker() + + self.stub_gh(sha=HEAD_SHA) + first = self.parse(self.run_script(environment={"APP_PR": "123"}).stdout)["APP_IMAGE"] + self.stub_gh(sha=other_sha) + second = self.parse(self.run_script(environment={"APP_PR": "123"}).stdout)["APP_IMAGE"] + + self.assertNotEqual(first, second) + self.assertTrue(first.endswith(HEAD_SHA)) + self.assertTrue(second.endswith(other_sha)) + + def test_different_pull_requests_do_not_share_an_image(self): + self.stub_gh() + self.stub_docker() + + images = { + self.parse(self.run_script(environment={"APP_PR": number}).stdout)["APP_IMAGE"] + for number in ("100", "101", "102") + } + + self.assertEqual(3, len(images)) + + def test_temporary_images_use_their_own_namespace(self): + self.stub_gh() + self.stub_docker() + + image = self.parse( + self.run_script( + environment={"APP_PR": "123", "GITHUB_REPOSITORY": "semaphoreui/integration-tests"} + ).stdout + )["APP_IMAGE"] + + self.assertEqual( + f"ghcr.io/semaphoreui/integration-tests/semaphore-ci:ci-pr-123-{HEAD_SHA}", image + ) + self.assertNotIn("semaphoreui/semaphore:", image) + + +class ClosedApplicationPullRequestTest(AppSourceTestCase): + def test_a_merged_application_pull_request_falls_back_to_normal_mode(self): + self.stub_gh(state="closed") + self.stub_docker() + + result = self.run_script( + "resolve", environment={"APP_LINK_BODY": "Application-PR: #123"} + ) + values = self.parse(result.stdout) + + self.assertEqual("docker-image", values["APP_SOURCE"]) + self.assertEqual("closed", values["APP_PR_STATE"]) + self.assertEqual("", values["APP_IMAGE"]) + self.assertEqual("false", values["APP_BUILD_REQUIRED"]) + self.assertIn("no version left to test", result.stdout) + self.assertIn("Remove the Application-PR line", result.stdout) + self.assertIn("Application build: skipped", result.stdout) + + def test_an_open_application_pull_request_still_builds(self): + self.stub_gh(state="open") + self.stub_docker() + + values = self.parse(self.run_script(environment={"APP_PR": "123"}).stdout) + + self.assertEqual("pull-request", values["APP_SOURCE"]) + self.assertEqual("open", values["APP_PR_STATE"]) + self.assertEqual("true", values["APP_BUILD_REQUIRED"]) + + +class ErrorHandlingTest(AppSourceTestCase): + def test_a_missing_pull_request_fails(self): + self.stub_gh(pull_status=1, repository_status=0) + self.stub_docker() + + result = self.run_script(environment={"APP_PR": "123"}, expect_success=False) + + self.assertEqual(1, result.returncode) + self.assertIn("Application PR #123 not found", result.stderr) + + def test_an_unreachable_repository_fails(self): + self.stub_gh(pull_status=1, repository_status=1) + self.stub_docker() + + result = self.run_script(environment={"APP_PR": "123"}, expect_success=False) + + self.assertEqual(1, result.returncode) + self.assertIn("Unable to access application repository", result.stderr) + + def test_an_unusable_sha_fails(self): + self.stub_gh(sha="null") + self.stub_docker() + + result = self.run_script(environment={"APP_PR": "123"}, expect_success=False) + + self.assertEqual(1, result.returncode) + self.assertIn("Unable to resolve the HEAD SHA", result.stderr) + + def test_two_declarations_fail_instead_of_picking_one(self): + self.stub_gh() + self.stub_docker() + body = "Application-PR: #111\nApplication-PR: #222\n" + + result = self.run_script( + environment={"APP_LINK_BODY": body}, expect_success=False + ) + + self.assertEqual(1, result.returncode) + self.assertIn("declares Application-PR 2 times", result.stderr) + + def test_an_unusable_reference_fails(self): + self.stub_gh() + self.stub_docker() + + for reference in ("feature/BOOK-123", "#0", "https://github.com/semaphoreui/semaphore"): + with self.subTest(reference=reference): + result = self.run_script( + environment={"APP_LINK_BODY": f"Application-PR: {reference}"}, + expect_success=False, + ) + self.assertEqual(1, result.returncode) + self.assertIn("Application-PR in the pull request description", result.stderr) + + def test_a_missing_body_file_fails(self): + self.stub_gh() + self.stub_docker() + + result = self.run_script( + environment={"APP_LINK_BODY_FILE": str(self.root / "absent.md")}, + expect_success=False, + ) + + self.assertEqual(1, result.returncode) + self.assertIn("APP_LINK_BODY_FILE does not exist", result.stderr) + + def test_a_non_numeric_ci_input_fails(self): + self.stub_gh() + self.stub_docker() + + result = self.run_script(environment={"APP_PR": "feature/BOOK-123"}, expect_success=False) + + self.assertEqual(1, result.returncode) + self.assertIn("invalid application pull request number", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/main/java/io/bookwright/config/MainConfig.java b/src/main/java/io/bookwright/config/MainConfig.java index fadacc6..3f3475a 100644 --- a/src/main/java/io/bookwright/config/MainConfig.java +++ b/src/main/java/io/bookwright/config/MainConfig.java @@ -55,4 +55,12 @@ public interface MainConfig extends Config { @Key("teardown.failOnError") @DefaultValue("true") boolean teardownFailOnError(); + + @Key("git.fixtures.repository") + @DefaultValue("https://github.com/semaphoreui/integration-tests.git") + String fixturesRepository(); + + @Key("git.fixtures.branch") + @DefaultValue("main") + String fixturesDefaultBranch(); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java index 2af6dc7..7f58e54 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreBuildDeployFixtures.java @@ -6,6 +6,7 @@ import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TaskRequest; import io.bookwright.api.model.semaphore.TemplateRequest; +import io.bookwright.config.MainConfig; import io.bookwright.util.TestData; import java.util.List; @@ -18,27 +19,29 @@ public record SemaphoreBuildDeployFixtures( BuildTemplate build, DeployTemplate deploy) { - public static SemaphoreBuildDeployFixtures from(TestData data) { + public static SemaphoreBuildDeployFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreBuildDeployFixtures( new ProjectRequest("bookwright-build-deploy-" + suffix, false, 0), new AccessKey("bookwright-build-deploy-key-" + suffix, "none"), new Repository( - "bookwright-build-deploy-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-build-deploy-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Inventory( "bookwright-build-deploy-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", "static"), new BuildTemplate( "bookwright-build-template-" + suffix, - "build-version.yml", + "test-environment/fixtures/ansible/build-version.yml", "ansible", "build", "1.2.3", "semaphore-bookwright-build-version"), new DeployTemplate( "bookwright-deploy-template-" + suffix, - "deploy-version.yml", + "test-environment/fixtures/ansible/deploy-version.yml", "ansible", "deploy", "semaphore-bookwright-deploy-version")); diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreConcurrencyFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreConcurrencyFixtures.java index 61cf283..c47b12d 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreConcurrencyFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreConcurrencyFixtures.java @@ -23,7 +23,7 @@ public static SemaphoreConcurrencyFixtures from(TestData data) { return new SemaphoreConcurrencyFixtures( "bookwright-concurrency-" + suffix, "bookwright-parallel-template-" + suffix, - "long-running.yml", + "test-environment/fixtures/ansible/long-running.yml", 1, 2, "semaphore-bookwright-stop-ready", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java index 46c3bd4..37b6d2d 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreEncryptionRotationFixtures.java @@ -1,6 +1,7 @@ package io.bookwright.fixtures.semaphore; import io.bookwright.api.model.semaphore.ProjectRequest; +import io.bookwright.config.MainConfig; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Inventory; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Repository; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.SecretAccessKey; @@ -17,7 +18,7 @@ public record SemaphoreEncryptionRotationFixtures( Template template, String outputMarker) { - public static SemaphoreEncryptionRotationFixtures standard() { + public static SemaphoreEncryptionRotationFixtures from(MainConfig config) { return new SemaphoreEncryptionRotationFixtures( new ProjectRequest("bookwright-encryption-rotation", false, 0), new SecretAccessKey( @@ -35,12 +36,19 @@ public static SemaphoreEncryptionRotationFixtures standard() { "login_password", "bookwright-post-rekey-user", "Bookwright-post-rekey-password-42!"), - new Repository("bookwright-encryption-repository", "file:///fixtures/ansible", "main"), + new Repository( + "bookwright-encryption-repository", + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Inventory( "bookwright-encryption-inventory", "[local]\nlocalhost ansible_connection=local", "static"), - new Template("bookwright-encryption-template", "smoke.yml", "ansible", ""), + new Template( + "bookwright-encryption-template", + "test-environment/fixtures/ansible/smoke.yml", + "ansible", + ""), "semaphore-bookwright-smoke-ok"); } } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java index 6ab4d61..d6ac63f 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFileInventoryFixtures.java @@ -6,6 +6,7 @@ import io.bookwright.api.model.semaphore.ProjectRequest; import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TemplateRequest; +import io.bookwright.config.MainConfig; import io.bookwright.util.TestData; /** Typed data for Ansible inventories stored in a Git repository. */ @@ -19,21 +20,28 @@ public record SemaphoreFileInventoryFixtures( String successfulTaskStatus, String outputMarker) { - public static SemaphoreFileInventoryFixtures from(TestData data) { + public static SemaphoreFileInventoryFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreFileInventoryFixtures( new ProjectRequest("bookwright-file-inventory-" + suffix, false, 0), new AccessKey("bookwright-file-inventory-key-" + suffix, "none"), new Repository( - "bookwright-file-inventory-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-file-inventory-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new FileInventory( - "bookwright-file-inventory-" + suffix, "inventories/localhost.ini", "file"), + "bookwright-file-inventory-" + suffix, + "test-environment/fixtures/ansible/inventories/localhost.ini", + "file"), new FileInventory( "bookwright-unsafe-file-inventory-" + suffix, "../bookwright-outside-repository.ini", "file"), new Template( - "bookwright-file-inventory-template-" + suffix, "file-inventory.yml", "ansible", ""), + "bookwright-file-inventory-template-" + suffix, + "test-environment/fixtures/ansible/file-inventory.yml", + "ansible", + ""), "success", "semaphore-bookwright-file-inventory-ok"); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java index eceb858..2274fb0 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreFixtures.java @@ -44,14 +44,16 @@ public static SemaphoreFixtures from(MainConfig config, TestData data) { "Bw-secret-" + suffix + "-42!"), new Repositories( new Repository( - "bookwright-demo-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-demo-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Repository( "bookwright-ref-repository-" + suffix, - "file:///fixtures/ansible", + config.fixturesRepository(), "bookwright-fixture-ref"), new Repository( "bookwright-missing-ref-repository-" + suffix, - "file:///fixtures/ansible", + config.fixturesRepository(), "bookwright-missing-ref"), new Repository( "bookwright-unavailable-repository-" + suffix, @@ -62,9 +64,16 @@ public static SemaphoreFixtures from(MainConfig config, TestData data) { "[local]\nlocalhost ansible_connection=local", "static"), new Templates( - new Template("bookwright-build-template-" + suffix, "smoke.yml", "ansible", ""), new Template( - "bookwright-stoppable-template-" + suffix, "long-running.yml", "ansible", "")), + "bookwright-build-template-" + suffix, + "test-environment/fixtures/ansible/smoke.yml", + "ansible", + ""), + new Template( + "bookwright-stoppable-template-" + suffix, + "test-environment/fixtures/ansible/long-running.yml", + "ansible", + "")), new Schedule("bookwright-nightly-schedule-" + suffix, "0 0 * * *", false, ""), Rbac.standard(), new Expectations( diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java index 6645f2a..eb96aa5 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreHttpsGitFixtures.java @@ -41,7 +41,11 @@ public static SemaphoreHttpsGitFixtures from(TestData data) { "bookwright-https-git-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", "static"), - new Template("bookwright-https-git-template-" + suffix, "smoke.yml", "ansible", ""), + new Template( + "bookwright-https-git-template-" + suffix, + "test-environment/fixtures/ansible/smoke.yml", + "ansible", + ""), "success", "error", "semaphore-bookwright-smoke-ok", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreIntegrationFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreIntegrationFixtures.java index e61839d..d8089d4 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreIntegrationFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreIntegrationFixtures.java @@ -29,7 +29,7 @@ public static SemaphoreIntegrationFixtures from(TestData data) { "bookwright-webhook-project-" + suffix, "bookwright-webhook-" + suffix, "bookwright-webhook-template-" + suffix, - "integration-webhook.yml", + "test-environment/fixtures/ansible/integration-webhook.yml", new SemaphoreFixtures.SecretAccessKey( "bookwright-webhook-token-" + suffix, "login_password", diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java index 7e717c6..90dcb81 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreProjectDeletionFixtures.java @@ -5,6 +5,7 @@ import io.bookwright.api.model.semaphore.ProjectRequest; import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TemplateRequest; +import io.bookwright.config.MainConfig; import io.bookwright.util.TestData; /** Typed data for project deletion with a running or stopped task. */ @@ -17,19 +18,24 @@ public record SemaphoreProjectDeletionFixtures( String readyMarker, String stoppedTaskStatus) { - public static SemaphoreProjectDeletionFixtures from(TestData data) { + public static SemaphoreProjectDeletionFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreProjectDeletionFixtures( new ProjectRequest("bookwright-project-delete-" + suffix, false, 0), new AccessKey("bookwright-project-delete-key-" + suffix, "none"), new Repository( - "bookwright-project-delete-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-project-delete-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Inventory( "bookwright-project-delete-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", "static"), new Template( - "bookwright-project-delete-template-" + suffix, "project-deletion.yml", "ansible", ""), + "bookwright-project-delete-template-" + suffix, + "test-environment/fixtures/ansible/project-deletion.yml", + "ansible", + ""), "semaphore-bookwright-project-delete-ready", "stopped"); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreRunnerRoutingFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreRunnerRoutingFixtures.java index 9abaeeb..e3b43cb 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreRunnerRoutingFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreRunnerRoutingFixtures.java @@ -31,7 +31,7 @@ public static SemaphoreRunnerRoutingFixtures from(TestData data) { "bookwright-missing", "bookwright-tagged-template-" + suffix, "bookwright-unmatched-template-" + suffix, - "long-running.yml", + "test-environment/fixtures/ansible/long-running.yml", "semaphore-bookwright-stop-ready", 2, 1, diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java index b69cfac..f25fdea 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreShellOutputFixtures.java @@ -5,6 +5,7 @@ import io.bookwright.api.model.semaphore.ProjectRequest; import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TemplateRequest; +import io.bookwright.config.MainConfig; import io.bookwright.util.TestData; import java.time.Duration; @@ -17,13 +18,15 @@ public record SemaphoreShellOutputFixtures( Templates templates, Expectations expectations) { - public static SemaphoreShellOutputFixtures from(TestData data) { + public static SemaphoreShellOutputFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreShellOutputFixtures( new ProjectRequest("bookwright-shell-output-" + suffix, false, 0), new AccessKey("bookwright-shell-output-key-" + suffix, "none"), new Repository( - "bookwright-shell-output-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-shell-output-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Inventory( "bookwright-shell-output-inventory-" + suffix, "[local]\nlocalhost ansible_connection=local", @@ -31,12 +34,12 @@ public static SemaphoreShellOutputFixtures from(TestData data) { new Templates( new Template( "bookwright-shell-output-template-" + suffix, - "bash/capture-output/normal.sh", + "test-environment/fixtures/ansible/bash/capture-output/normal.sh", "bash", ""), new Template( "bookwright-background-shell-output-template-" + suffix, - "bash/capture-output/background.sh", + "test-environment/fixtures/ansible/bash/capture-output/background.sh", "bash", "")), new Expectations( diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java index fec24bf..20f1825 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreStaticInventoryFixtures.java @@ -6,6 +6,7 @@ import io.bookwright.api.model.semaphore.ProjectRequest; import io.bookwright.api.model.semaphore.RepositoryRequest; import io.bookwright.api.model.semaphore.TemplateRequest; +import io.bookwright.config.MainConfig; import io.bookwright.util.TestData; import java.util.List; @@ -20,13 +21,15 @@ public record SemaphoreStaticInventoryFixtures( Template yamlTemplate, String outputMarker) { - public static SemaphoreStaticInventoryFixtures from(TestData data) { + public static SemaphoreStaticInventoryFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreStaticInventoryFixtures( new ProjectRequest("bookwright-static-inventory-" + suffix, false, 0), new AccessKey("bookwright-static-inventory-key-" + suffix, "none"), new Repository( - "bookwright-static-inventory-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-static-inventory-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new StaticInventory( "bookwright-ini-inventory-" + suffix, "[bookwright_selected]\n" @@ -38,7 +41,7 @@ public static SemaphoreStaticInventoryFixtures from(TestData data) { "excluded-host"), new Template( "bookwright-ini-inventory-template-" + suffix, - "smoke.yml", + "test-environment/fixtures/ansible/smoke.yml", "ansible", "", "bookwright_selected"), @@ -61,7 +64,7 @@ public static SemaphoreStaticInventoryFixtures from(TestData data) { "yaml-excluded-host"), new Template( "bookwright-yaml-inventory-template-" + suffix, - "smoke.yml", + "test-environment/fixtures/ansible/smoke.yml", "ansible", "", "bookwright_yaml_selected"), diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreSurveyFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreSurveyFixtures.java index 0687b07..8dc9c2a 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreSurveyFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreSurveyFixtures.java @@ -72,7 +72,7 @@ public static SemaphoreSurveyFixtures from(TestData data) { null)); return new SemaphoreSurveyFixtures( "bookwright-survey-template-" + suffix, - "survey-overrides.yml", + "test-environment/fixtures/ansible/survey-overrides.yml", surveyVariables, new AnsibleTemplateParameters( true, false, true, true, true, true, false, List.of(), List.of(), List.of()), diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java index 1ee9cfa..8425a59 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreTerraformFixtures.java @@ -10,6 +10,7 @@ import io.bookwright.api.model.semaphore.TerraformTemplateParameters; import io.bookwright.api.model.semaphore.VariableGroupRequest; import io.bookwright.api.model.semaphore.VariableGroupSecretRequest; +import io.bookwright.config.MainConfig; import io.bookwright.util.TestData; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -27,13 +28,15 @@ public record SemaphoreTerraformFixtures( Tool tofu, String workspaceOutputName) { - public static SemaphoreTerraformFixtures from(TestData data) { + public static SemaphoreTerraformFixtures from(MainConfig config, TestData data) { String suffix = Long.toUnsignedString(data.testSeed(), 36); return new SemaphoreTerraformFixtures( new ProjectRequest("bookwright-terraform-" + suffix, false, 0), new AccessKey("bookwright-terraform-key-" + suffix, "none"), new Repository( - "bookwright-terraform-repository-" + suffix, "file:///fixtures/ansible", "main"), + "bookwright-terraform-repository-" + suffix, + config.fixturesRepository(), + config.fixturesDefaultBranch()), new TerraformVariableGroup( "bookwright-terraform-variables-" + suffix, "TF_VAR_bookwright_secret", @@ -46,13 +49,18 @@ public static SemaphoreTerraformFixtures from(TestData data) { "bookwright-tf-" + suffix, "terraform-workspace"), new ToolTemplate( - "bookwright-terraform-template-" + suffix, "terraform-workspace", "terraform")), + "bookwright-terraform-template-" + suffix, + "test-environment/fixtures/ansible/terraform-workspace", + "terraform")), new Tool( new WorkspaceInventory( "bookwright-tofu-workspace-" + suffix, "bookwright-tofu-" + suffix, "tofu-workspace"), - new ToolTemplate("bookwright-tofu-template-" + suffix, "terraform-workspace", "tofu")), + new ToolTemplate( + "bookwright-tofu-template-" + suffix, + "test-environment/fixtures/ansible/terraform-workspace", + "tofu")), "semaphore_bookwright_workspace"); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java index 743bbf0..2dd0d86 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreUpgradeFixtures.java @@ -1,6 +1,7 @@ package io.bookwright.fixtures.semaphore; import io.bookwright.api.model.semaphore.ProjectRequest; +import io.bookwright.config.MainConfig; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Inventory; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Repository; import io.bookwright.fixtures.semaphore.SemaphoreFixtures.Schedule; @@ -17,7 +18,7 @@ public record SemaphoreUpgradeFixtures( Schedule schedule, String outputMarker) { - public static SemaphoreUpgradeFixtures standard() { + public static SemaphoreUpgradeFixtures from(MainConfig config) { return new SemaphoreUpgradeFixtures( new ProjectRequest("bookwright-release-upgrade", false, 0), new SecretAccessKey( @@ -25,12 +26,19 @@ public static SemaphoreUpgradeFixtures standard() { "login_password", "bookwright-upgrade-user", "Bookwright-upgrade-password-42!"), - new Repository("bookwright-upgrade-repository", "file:///fixtures/ansible", "main"), + new Repository( + "bookwright-upgrade-repository", + config.fixturesRepository(), + config.fixturesDefaultBranch()), new Inventory( "bookwright-upgrade-inventory", "[local]\nlocalhost ansible_connection=local", "static"), - new Template("bookwright-upgrade-template", "smoke.yml", "ansible", ""), + new Template( + "bookwright-upgrade-template", + "test-environment/fixtures/ansible/smoke.yml", + "ansible", + ""), new Schedule("bookwright-upgrade-schedule", "0 0 * * *", false, ""), "semaphore-bookwright-smoke-ok"); } diff --git a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreVariableGroupFixtures.java b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreVariableGroupFixtures.java index a4918d5..6aeca0c 100644 --- a/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreVariableGroupFixtures.java +++ b/src/main/java/io/bookwright/fixtures/semaphore/SemaphoreVariableGroupFixtures.java @@ -39,7 +39,7 @@ public static SemaphoreVariableGroupFixtures from(TestData data) { variableSecret, environmentSecret, "bookwright-variable-template-" + suffix, - "variables.yml", + "test-environment/fixtures/ansible/variables.yml", "semaphore-bookwright-variable-group-ok", "Environment variables key can not be empty"); } diff --git a/src/main/java/io/bookwright/junit/StepsParameterResolver.java b/src/main/java/io/bookwright/junit/StepsParameterResolver.java index 850f727..e1b3d66 100644 --- a/src/main/java/io/bookwright/junit/StepsParameterResolver.java +++ b/src/main/java/io/bookwright/junit/StepsParameterResolver.java @@ -109,13 +109,14 @@ public Object resolveParameter( return HotelDatabaseFixtures.seeded(); } if (type == SemaphoreEncryptionRotationFixtures.class) { - return SemaphoreEncryptionRotationFixtures.standard(); + return SemaphoreEncryptionRotationFixtures.from(io.bookwright.config.Configs.main()); } if (type == SemaphoreBackupFixtures.class) { return SemaphoreBackupFixtures.from(TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreBuildDeployFixtures.class) { - return SemaphoreBuildDeployFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreBuildDeployFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreConcurrencyFixtures.class) { return SemaphoreConcurrencyFixtures.from(TestDataExtension.getOrCreate(extensionContext)); @@ -125,7 +126,8 @@ public Object resolveParameter( io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreFileInventoryFixtures.class) { - return SemaphoreFileInventoryFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreFileInventoryFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreHttpsGitFixtures.class) { return SemaphoreHttpsGitFixtures.from(TestDataExtension.getOrCreate(extensionContext)); @@ -144,7 +146,8 @@ public Object resolveParameter( return SemaphoreOidcFixtures.standard(); } if (type == SemaphoreProjectDeletionFixtures.class) { - return SemaphoreProjectDeletionFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreProjectDeletionFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreRunnerRoutingFixtures.class) { return SemaphoreRunnerRoutingFixtures.from(TestDataExtension.getOrCreate(extensionContext)); @@ -153,19 +156,22 @@ public Object resolveParameter( return SemaphoreScheduleFixtures.from(TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreShellOutputFixtures.class) { - return SemaphoreShellOutputFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreShellOutputFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreSshFixtures.class) { return SemaphoreSshFixtures.from(TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreStaticInventoryFixtures.class) { - return SemaphoreStaticInventoryFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreStaticInventoryFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreSurveyFixtures.class) { return SemaphoreSurveyFixtures.from(TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreTerraformFixtures.class) { - return SemaphoreTerraformFixtures.from(TestDataExtension.getOrCreate(extensionContext)); + return SemaphoreTerraformFixtures.from( + io.bookwright.config.Configs.main(), TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreTotpFixtures.class) { return SemaphoreTotpFixtures.standard(); @@ -174,7 +180,7 @@ public Object resolveParameter( return SemaphoreTokenFixtures.from(TestDataExtension.getOrCreate(extensionContext)); } if (type == SemaphoreUpgradeFixtures.class) { - return SemaphoreUpgradeFixtures.standard(); + return SemaphoreUpgradeFixtures.from(io.bookwright.config.Configs.main()); } if (type == SemaphoreUserLifecycleFixtures.class) { return SemaphoreUserLifecycleFixtures.from(TestDataExtension.getOrCreate(extensionContext)); diff --git a/src/test/java/io/bookwright/tests/framework/FixtureArchitectureTest.java b/src/test/java/io/bookwright/tests/framework/FixtureArchitectureTest.java index e5e4cfa..6f51b96 100644 --- a/src/test/java/io/bookwright/tests/framework/FixtureArchitectureTest.java +++ b/src/test/java/io/bookwright/tests/framework/FixtureArchitectureTest.java @@ -118,13 +118,13 @@ void fixtureDiagnosticsRedactPasswords() { SemaphoreSshFixtures.SshAccessKey sshKey = new SemaphoreSshFixtures.SshAccessKey( "fixture-key", "ssh", "fixture", "ssh-passphrase-secret", "ssh-private-key-secret"); - SemaphoreUpgradeFixtures upgrade = SemaphoreUpgradeFixtures.standard(); + SemaphoreUpgradeFixtures upgrade = SemaphoreUpgradeFixtures.from(Configs.main()); SemaphoreVariableGroupFixtures variableGroup = SemaphoreVariableGroupFixtures.from(new TestData(1L, 2L, "fixture-redaction")); SemaphoreSurveyFixtures survey = SemaphoreSurveyFixtures.from(new TestData(1L, 2L, "fixture-redaction")); SemaphoreTerraformFixtures terraform = - SemaphoreTerraformFixtures.from(new TestData(1L, 2L, "fixture-redaction")); + SemaphoreTerraformFixtures.from(Configs.main(), new TestData(1L, 2L, "fixture-redaction")); SemaphoreUserLifecycleFixtures userLifecycle = SemaphoreUserLifecycleFixtures.from(new TestData(1L, 2L, "fixture-redaction")); diff --git a/test-environment/profile b/test-environment/profile index 020875c..f6c5ea8 100755 --- a/test-environment/profile +++ b/test-environment/profile @@ -30,6 +30,10 @@ verifies the persisted data and task execution. encryption-rotation-test is available only for encryption rotation profiles. It creates data with the old primary key, hot-reloads a new primary, rekeys database secrets, removes the retired key and verifies the persisted task fixture. + +The application image comes from the profile manifest. Set APP_IMAGE to run the +same profile against another image, for example one built from a pull request of +the application repository by scripts/app-source.sh. EOF } @@ -43,6 +47,28 @@ manifest_value() { sed -n "s/^${key}:[[:space:]]*//p" "$manifest_file" | sed -n '1p' } +# The application under test normally comes from the profile manifest. APP_IMAGE overrides it +# with a temporary image built from an application pull request; see scripts/app-source.sh. +effective_semaphore_image() { + if [ -n "${APP_IMAGE:-}" ]; then + printf '%s' "$APP_IMAGE" + else + manifest_value semaphore_image + fi +} + +report_application_source() { + if [ -n "${APP_IMAGE:-}" ]; then + printf 'Application source: Pull Request\n' + [ -z "${APP_REPOSITORY:-}" ] || printf 'Application repository: %s\n' "$APP_REPOSITORY" + [ -z "${APP_PR:-}" ] || printf 'Application PR: #%s\n' "$APP_PR" + [ -z "${APP_SHA:-}" ] || printf 'Application SHA: %s\n' "$APP_SHA" + else + printf 'Application source: Docker image\n' + fi + printf 'Application image: %s\n' "$selected_semaphore_image" +} + select_profile() { profile_id=$1 case "$profile_id" in @@ -58,7 +84,7 @@ select_profile() { readiness_url=$(manifest_value readiness_url) stand=$(manifest_value stand) setup_service=$(manifest_value setup_service) - selected_semaphore_image=$(manifest_value semaphore_image) + selected_semaphore_image=$(effective_semaphore_image) selected_schedule_timezone=$(manifest_value schedule_timezone) [ -n "$selected_schedule_timezone" ] || selected_schedule_timezone=UTC selected_test_task=$(manifest_value test_task) @@ -295,7 +321,7 @@ write_allure_environment() { allure_dir="$repository_dir/build/allure-results" mkdir -p "$allure_dir" - image=$(manifest_value semaphore_image) + image=$selected_semaphore_image image_reference=$(docker image inspect "$image" --format '{{index .RepoDigests 0}}' 2>/dev/null || true) [ -n "$image_reference" ] || image_reference=$image @@ -329,9 +355,19 @@ write_allure_environment() { { printf 'profile=%s\n' "$profile_id" - printf 'semaphore.version=%s\n' "$(manifest_value semaphore_version)" - printf 'semaphore.image=%s\n' "$image_reference" - printf 'semaphore.source.commit=%s\n' "$(manifest_value source_commit)" + if [ -n "${APP_IMAGE:-}" ]; then + printf 'application.source=pull-request\n' + printf 'application.repository=%s\n' "${APP_REPOSITORY:-}" + printf 'application.pull.request=%s\n' "${APP_PR:-}" + printf 'semaphore.version=%s\n' "pr-${APP_PR:-unknown}" + printf 'semaphore.image=%s\n' "$image_reference" + printf 'semaphore.source.commit=%s\n' "${APP_SHA:-}" + else + printf 'application.source=docker-image\n' + printf 'semaphore.version=%s\n' "$(manifest_value semaphore_version)" + printf 'semaphore.image=%s\n' "$image_reference" + printf 'semaphore.source.commit=%s\n' "$(manifest_value source_commit)" + fi printf 'semaphore.edition=%s\n' "$(manifest_value edition)" printf 'installation=%s\n' "$(manifest_value installation)" printf 'architecture=%s\n' "$(uname -m)" @@ -452,6 +488,7 @@ case "$action" in ;; up) select_profile "${2:-}" + report_application_source prepare_ssh_fixture prepare_tls_fixture prepare_git_https_fixture @@ -467,6 +504,7 @@ case "$action" in select_profile "${2:-}" [ "$selected_encryption_fixture" != "generated" ] \ || fail "profile '$profile_id' requires: test-environment/profile encryption-rotation-test $profile_id" + report_application_source shift 2 prepare_ssh_fixture prepare_tls_fixture @@ -515,7 +553,7 @@ case "$action" in await_ready run_upgrade_phase seed - selected_semaphore_image=$(manifest_value semaphore_image) + selected_semaphore_image=$(effective_semaphore_image) compose up --detach --force-recreate semaphore await_ready write_allure_environment