Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions .github/workflows/_prepare-app-image.yml
Original file line number Diff line number Diff line change
@@ -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"
128 changes: 128 additions & 0 deletions .github/workflows/application-pr.yml
Original file line number Diff line number Diff line change
@@ -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"
58 changes: 56 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -136,6 +189,7 @@ jobs:
name: Build Allure report
if: ${{ always() }}
needs:
- app-image
- quality
- core-sqlite
permissions:
Expand Down
Loading
Loading