diff --git a/.github/workflows/qubes-dom0-package.yml b/.github/workflows/qubes-dom0-package.yml index dee3f8b..00d8431 100644 --- a/.github/workflows/qubes-dom0-package.yml +++ b/.github/workflows/qubes-dom0-package.yml @@ -42,11 +42,11 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 100 # need history for `git format-patch` - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: repository: TrenchBoot/.github path: shared diff --git a/.github/workflows/qubes-dom0-packagev2.yml b/.github/workflows/qubes-dom0-packagev2.yml index d29e113..7243bd4 100644 --- a/.github/workflows/qubes-dom0-packagev2.yml +++ b/.github/workflows/qubes-dom0-packagev2.yml @@ -23,6 +23,11 @@ on: Forced version of a package. required: false type: string + qubes-component-branch: + description: > + Forced repository branch to build component from + required: false + type: string jobs: build-and-package: @@ -40,7 +45,7 @@ jobs: createrepo-c devscripts python3-docker reprepro \ python3-pathspec mktorrent python3-lxml python3-dateutil - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: repository: QubesOS/qubes-builderv2 ref: 80dd898cc0472dd99f161f1d1c7c44da64de93f2 @@ -79,6 +84,7 @@ jobs: PKG_DIR: ${{ inputs.qubes-pkg-src-dir }} PKG_REV: ${{ inputs.qubes-pkg-revision }} PKG_VER: ${{ inputs.qubes-pkg-version }} + BUILD_BRANCH: ${{ inputs.qubes-component-branch }} # Following 2 variables are used in double expansion '${${{ github.ref_type }}}', # do not change these names even though they don't follow the convention. branch: ${{ github.head_ref }} @@ -88,11 +94,14 @@ jobs: # Switch from Qubes to Docker executor sed -i "/^executor:$/,+4d; /^#executor:$/,+3s/#//" builder.yml - branch_name=${${{ github.ref_type }}} + branch_name="${BUILD_BRANCH}" if [ -z "$branch_name" ]; then - # github.head_ref is set only for pull requests, this should - # handle pushes - branch_name=$(basename "$GITHUB_REF") + branch_name=${${{ github.ref_type }}} + if [ -z "$branch_name" ]; then + # github.head_ref is set only for pull requests, this should + # handle pushes + branch_name=$(basename "$GITHUB_REF") + fi fi if [ -n "$PKG_DIR" ]; then diff --git a/.github/workflows/rebase.yml b/.github/workflows/rebase.yml new file mode 100644 index 0000000..8b72a8b --- /dev/null +++ b/.github/workflows/rebase.yml @@ -0,0 +1,104 @@ +--- +name: Try rebasing on updated upstream, report in case of conflicts + +on: + workflow_call: + secrets: + first-remote-token: + description: > + Personal access token for performing the following operations on the + downstream-repo: fetch the repository, create a branch, delete a + branch, create commits on a branch, push to a branch, open a PR, close + a PR, get list of PRs. + required: true + inputs: + downstream-repo: + description: > + parameter for the rebase.sh script. + required: true + type: string + downstream-branch: + description: > + parameter for the rebase.sh script. + required: true + type: string + upstream-repo: + description: > + parameter for the rebase.sh script. + required: true + type: string + upstream-branch: + description: > + parameter for the rebase.sh script. + required: true + type: string + commit-user-name: + description: > + NAME parameter for the --commit-user-name option of the rebase.sh + script. + required: true + type: string + commit-user-email: + description: > + EMAIL parameter for the --commit-user-email option of the rebase.sh + script. + required: true + type: string + cicd-trigger-resume: + description: > + MESSAGE parameter for the --cicd-trigger-resume option of the + rebase.sh script. + required: true + type: string + outputs: + rebase-exit-code: + description: > + Exit code returned by the rebase.sh script. See the script's --help + output for the meaning of each code. + value: ${{ jobs.rebase-attempt.outputs.rebase-exit-code }} + +jobs: + rebase-attempt: + runs-on: ubuntu-latest + name: Try rebasing on updated upstream, report in case of conflicts + permissions: + # For creation/deletion/pushing to branches and creating PRs + contents: write + outputs: + rebase-exit-code: ${{ steps.rebase.outputs.exit-code }} + steps: + - uses: actions/checkout@v6 + with: + repository: TrenchBoot/.github + path: shared + ref: ${{ job.workflow_sha }} + - name: Run script for rebasing + id: rebase + env: + FIRST_REMOTE_TOKEN: ${{ secrets.first-remote-token }} + DOWNSTREAM_REPO: ${{ inputs.downstream-repo }} + DOWNSTREAM_BRANCH: ${{ inputs.downstream-branch }} + UPSTREAM_REPO: ${{ inputs.upstream-repo }} + UPSTREAM_BRANCH: ${{ inputs.upstream-branch }} + NAME: ${{ inputs.commit-user-name }} + EMAIL: ${{ inputs.commit-user-email }} + MESSAGE: ${{ inputs.cicd-trigger-resume }} + run: | + set +e + shared/scripts/rebase.sh --first-remote-token "$FIRST_REMOTE_TOKEN" \ + --commit-user-name "$NAME" \ + --commit-user-email "$EMAIL" \ + --cicd-trigger-resume "$MESSAGE" \ + "$DOWNSTREAM_REPO" \ + "$DOWNSTREAM_BRANCH" \ + "$UPSTREAM_REPO" \ + "$UPSTREAM_BRANCH" + rc=$? + echo "exit-code=${rc}" >> "$GITHUB_OUTPUT" + # The "No rebase needed" return code should be considered a success + # here, as we do not want to show that a job has failed in that case + # to avoid drawing attention of maintainers. + if [ "$rc" -eq "5" ]; then + exit "0" + fi + exit "${rc}" diff --git a/.github/workflows/trigger-woodpecker-pipeline.yml b/.github/workflows/trigger-woodpecker-pipeline.yml new file mode 100644 index 0000000..e693a92 --- /dev/null +++ b/.github/workflows/trigger-woodpecker-pipeline.yml @@ -0,0 +1,73 @@ +name: Trigger a Woodpecker CI/CD pipeline + +on: + workflow_call: + inputs: + api-url: + description: > + Base URL of the Woodpecker instance, e.g. https://ci.example.com. + --api-url parameter for the woodpecker-trigger.sh script. + required: true + type: string + owner: + description: > + Repository owner (user or organization). + --owner parameter for the woodpecker-trigger.sh script. + required: true + type: string + repo: + description: > + Repository name. + --repo parameter for the woodpecker-trigger.sh script. + required: true + type: string + ref: + description: > + Branch to trigger the pipeline on. + --ref parameter for the woodpecker-trigger.sh script. + required: false + type: string + default: 'main' + inputs: + description: > + Additional --input flags to pass to woodpecker-trigger.sh, e.g. + "--input KEY=VALUE --input KEY2=VALUE2". Keys must be valid shell + variable names (no hyphens). + required: false + type: string + default: '' + secrets: + woodpecker-token: + description: > + Woodpecker API token for triggering the pipeline. + --token parameter for the woodpecker-trigger.sh script. + required: true + +jobs: + trigger-woodpecker: + runs-on: ubuntu-latest + name: Trigger a Woodpecker CI/CD pipeline + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + repository: TrenchBoot/.github + path: shared + ref: ${{ job.workflow_sha }} + - name: Trigger Woodpecker CI/CD pipeline + env: + WOODPECKER_TOKEN: ${{ secrets.woodpecker-token }} + WOODPECKER_API_URL: ${{ inputs.api-url }} + WOODPECKER_OWNER: ${{ inputs.owner }} + WOODPECKER_REPO: ${{ inputs.repo }} + WOODPECKER_REF: ${{ inputs.ref }} + WOODPECKER_INPUTS: ${{ inputs.inputs }} + run: | + shared/scripts/woodpecker-trigger.sh \ + --token "$WOODPECKER_TOKEN" \ + --api-url "$WOODPECKER_API_URL" \ + --owner "$WOODPECKER_OWNER" \ + --repo "$WOODPECKER_REPO" \ + --ref "$WOODPECKER_REF" \ + $WOODPECKER_INPUTS diff --git a/README.md b/README.md index 5a49378..3ae6cb4 100644 --- a/README.md +++ b/README.md @@ -68,21 +68,52 @@ package, hence significantly reduced set of parameters. There is also no need to use `qubes-builder-docker/` in this case because builder's repository contains its own Docker image. -| Parameter | Type | Req. | Def. | Description -| --------- | ---- | ---- | ---- | ----------- -| `qubes-component` | string | Yes | - | Name of QubesOS component as recognized by its build system. -| `qubes-pkg-src-dir` | string | No | - | Relative path to directory containing Qubes OS package. -| `qubes-pkg-version` | string | No | auto | Version for RPM packages -| `qubes-pkg-revision` | string | No | `1` | Revision for RPM packages - -Used by [TrenchBoot/qubes-antievilmaid][aem] and -[TrenchBoot/secure-kernel-loader][skl]. The latter makes use of -`qubes-pkg-src-dir` as Qubes OS package is stored within the repository itself. +| Parameter | Type | Req. | Def. | Description +| --------- | ---- | ---- | ---- | ----------- +| `qubes-component` | string | Yes | - | Name of QubesOS component as recognized by its build system. +| `qubes-pkg-src-dir` | string | No | - | Relative path to directory containing Qubes OS package. +| `qubes-pkg-version` | string | No | auto | Version for RPM packages +| `qubes-pkg-revision` | string | No | `1` | Revision for RPM packages +| `qubes-component-branch` | string | No | - | Forced repository branch to build component from [qubes-builder-v2]: https://github.com/QubesOS/qubes-builderv2 [aem]: https://github.com/TrenchBoot/qubes-antievilmaid/blob/2b6b796e31789fca599986c9cfb0a3ceced5967d/.github/workflows/build.yml [skl]: https://github.com/TrenchBoot/secure-kernel-loader +### rebase + +This workflow automates rebasing a downstream repository branch on top of an +upstream branch. On success, it pushes the rebased branch. If conflicts arise, +it opens a pull request against the downstream repository to ask for +resolution. + +| Parameter | Type | Req. | Def. | Description +| --------- | ---- | ---- | ---- | ----------- +| `downstream-repo` | string | Yes | - | URL of the repository to rebase (`` argument of `rebase.sh`). +| `downstream-branch` | string | Yes | - | Branch in the downstream repository to rebase (`` argument of `rebase.sh`). +| `upstream-repo` | string | Yes | - | URL of the repository that provides the new base (`` argument of `rebase.sh`). +| `upstream-branch` | string | Yes | - | Branch in the upstream repository to rebase onto (`` argument of `rebase.sh`). +| `commit-user-name` | string | Yes | - | Git author name used for rebase commits (`--commit-user-name` option of `rebase.sh`). +| `commit-user-email` | string | Yes | - | Git author e-mail used for rebase commits (`--commit-user-email` option of `rebase.sh`). +| `cicd-trigger-resume` | string | Yes | - | Human-readable message appended to the conflict PR describing how to resume the pipeline (`--cicd-trigger-resume` option of `rebase.sh`). +| `first-remote-token` | string | Yes | - | Personal access token with permissions to fetch, branch, commit, push, and open/close PRs on `downstream-repo`. Passed as a GitHub Actions secret. + +### trigger-woodpecker-pipeline + +This workflow is a generic wrapper for the woodpecker-trigger.sh script for +triggering Woodpecker CI/CD pipelines on some remote Woodpecker instance. As for +now it is used only for triggering the pipelines for signing RPM packages built +by the `qubes-dom0-package` and `qubes-dom0-packagev2` workflows. + +| Parameter | Type | Req. | Def. | Description +| --------- | ---- | ---- | ---- | ----------- +| `api-url` | string | Yes | - | Base URL of the Woodpecker instance, e.g. `https://ci.example.com`. +| `owner` | string | Yes | - | Repository owner (user or organization). +| `repo` | string | Yes | - | Repository name. +| `ref` | string | No | `main` | Branch to trigger the pipeline on. +| `inputs` | string | No | - | Additional `--input KEY=VALUE` flags passed to `woodpecker-trigger.sh`. Keys must be valid shell variable names (no hyphens). +| `woodpecker-token` | string | Yes | - | Woodpecker API token for authentication. Passed as a GitHub Actions secret. + ## Usage Full details can be found in [GitHub's documentation][workflow-docs] on @@ -91,12 +122,14 @@ modifications to workflows are necessary. [workflow-docs]: https://docs.github.com/en/actions/using-workflows/reusing-workflows +### qubes-dom0-package or qubes-dom0-packagev2 + Create a workflow file like `.github/workflows/build.yml` inside of your repository. It will have 3 parts: name, triggering conditions and invocation of one of the workflows defined here. Let's use [TrenchBoot/grub][grub] as an example. -### Name +#### Name ```yaml name: Test build and package QubesOS RPMs @@ -104,7 +137,7 @@ name: Test build and package QubesOS RPMs Specify workflow title used for identification in UI. -### Triggering conditions +#### Triggering conditions ```yaml on: @@ -118,7 +151,7 @@ on: Activate this workflow on push of any tag or a branch which starts with `intel-txt-aem` (including this branch, i.e. `*` can expand to an empty string). -### Workflow invocation +#### Workflow invocation ```yaml jobs: @@ -134,6 +167,101 @@ jobs: Invoke v1 workflow from `master` branch of this repository with the set of parameters as described in a section above. +### rebase + +`rebase` is typically one job in a larger workflow that first prepares the +upstream branch to rebase onto, then calls this workflow, and finally cleans up +any temporary branches. + +#### Triggering conditions + +There is no specific trigger condition that can be used to trigger pipelines +that contain this reusable workflow. So the developer is free to decide. But +there is one case: if the workflow that uses this reusable workflow has a +condition on push event, then the token provided via `first-remote-token` should +not have permissions to trigger CI/CDs. This is because the script used inside +this reusable workflow pushes to the remote repository several times. + +#### Workflow invocation + +```yaml +name: Rebase on top of QubesOS main + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 6' + +jobs: + try-rebase: + uses: TrenchBoot/.github/.github/workflows/rebase.yml@master + secrets: + first-remote-token: ${{secrets.TRENCHBOOT_REBASE_TOKEN}} + permissions: + # For creation/deletion/pushing to branches and creating PRs + contents: write + with: + downstream-repo: 'https://github.com/DaniilKl/qubes-antievilmaid.git' + downstream-branch: 'main' + upstream-repo: 'https://github.com/QubesOS/qubes-antievilmaid.git' + upstream-branch: 'main' + commit-user-name: 'github-actions[bot]' + commit-user-email: 'github-actions[bot]@users.noreply.github.com' + cicd-trigger-resume: '7. Rerun the workflow https://github.com/DaniilKl/qubes-antievilmaid/actions/runs/${{ github.run_id }} to resume automated rebase.' +``` + +### trigger-woodpecker-pipeline + +`trigger-woodpecker-pipeline` is meant to be added as an additional job to an +existing workflow, chained after a `qubes-dom0-package` or `qubes-dom0-packagev2` +job. + +#### Workflow invocation + +An example invocation: + +```yaml +jobs: + qubes-dom0-package: + needs: get-version + uses: TrenchBoot/.github/.github/workflows/qubes-dom0-packagev2.yml@master + with: + qubes-component: 'vmm-xen' + qubes-component-branch: 'aem-next-rebased' + qubes-pkg-src-dir: '.' + qubes-pkg-version: '4.19.4' + trigger-woodpecker-cicd: + needs: qubes-dom0-package + uses: TrenchBoot/.github/.github/workflows/trigger-woodpecker-pipeline.yml@master + secrets: + woodpecker-token: ${{ secrets.WOODPECKER_TOKEN }} + with: + api-url: 'https://ci.3mdeb.com' + owner: 'zarhus' + repo: 'trenchboot-release-cicd-pipeline' + ref: 'master' + inputs: >- + --input GITHUB_REPO=xen + --input GITHUB_SHA=${{ github.sha }} + --input GITHUB_RUN_ID=${{ github.run_id }} + --input QUBES_COMPONENT=vmm-xen + --input WORKFLOW=sign-and-publish-test-rpms +``` + +Invokes the workflow from `master` branch of this repository after the +`qubes-dom0-package` job completes. Pass the Woodpecker API token from the +repository's GitHub secrets, point it at the target Woodpecker instance +and repository, and supply any pipeline-specific key/value pairs via repeated +`--input` flags. + +Note, that all the inputs to the `trigger-woodpecker-pipeline.yml` except from +the `inputs` serve for the purpose of connection to the desired Woodpecker +instance on which a pipeline for signing is running. But the data provided via +`inputs` input and `--input` flag is consumed by the signing pipeline itself. +One must specify the name of the signing pipeline via `--input WORKFLOW=` and +all the input data the specified pipeline requires. The above example presents +the required inputs for the `sign-and-publish-test-rpms` pipeline. + ## Funding This project was partially funded through the diff --git a/scripts/rebase.sh b/scripts/rebase.sh new file mode 100755 index 0000000..a2d9d38 --- /dev/null +++ b/scripts/rebase.sh @@ -0,0 +1,846 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: 2026 3mdeb +# +# SPDX-License-Identifier: Apache-2.0 + +trap error_handling EXIT + +set -euo pipefail + +# Help +usage() { + cat < + +Rebase a branch from the first repo onto a branch from the second repo. On +conflict, stops the rebase and creates a conflict branch containing all commits +that rebased cleanly before the conflict. + +Note, that this script supports only HTTPS protocol for fetching remote +repositories. + +A developer then needs to solve the conflict manually according to the following +steps: +1. (For remote repos only) fetch the remote repository. +2. Enter the repository. +3. Checkout the conflict branch created by the script (check the conflict branch + naming below). +4. Cherry-pick the commit that introduced the conflict (the hash of the commit + will be reported by the script or will be a part of the conflict branch name). +5. Solve the conflict and apply the commit after solving the conflict on top of + the conflict branch (e.g., by using "git cherry-pick --continue"). +6. (For remote repos only) push the remote repository. +7. Try to do the rebase with this script again and wait either for a new + conflict or for the rebase to be finished. + +The steps will be accompanied by commands when printed by script when prompting +for conflict resolution. + +In case the --first-remote-token option is provided - the script will do the +following actions on the remote part of the first repository: +* Fetch the first repository using the TOKEN. The second repository will be + fetched using HTTPS without tokens or credentials. +* Will push and delete pushed by this script branches using the TOKEN on the + remote repository. The script will push and delete only the conflict branches. + And when the rebase will succeed it will push a branch with rebased commits + from the on top of the named + '-rebased'. +* Will create a PR from a conflict branch to the in case of + conflicts. + +Currently supported remotes: +* GitHub. + +Arguments: + first_repo HTTPS URL of the fork repository to clone, or path to the local + repository + first_repo_branch + Branch in the fork to rebase + second_repo HTTPS URL of the upstream repository to clone + second_repo_branch + Branch in the upstream to rebase onto + +Options: + -h, --help Show this help message and exit + -l, --local Treat the as local directory path and + skips . + --first-remote-token TOKEN + Access token for the user to access the first repository on + the remote. + --commit-user-name NAME + The name used for "git config user.name". If not provided - + the "github-actions[bot]" is being used as the default name. + This name is used when creating new commits during rebase. + --commit-user-email EMAIL + The email used for "git config user.email". If not provided - + the "github-actions[bot]@users.noreply.github.com" is being + used as default email. This email is used when creating new + commits during rebase. + --cicd-trigger-resume MESSAGE + When using the script with --first-remote-token, the script + creates a PR on the remote part of the with a + comment on how to resolve the conflict correctly. But by + default, the comment does not contain a message on how to + resume the automatic rebase with this script when the script + is used in CI/CD. The reason this information is missing is + that this script should not depend by default on whether + it is launched in CI/CD or not, hence this script by default + expects that it will be launched from a CLI. Hence, CI/CD + launches this script as a step in a job as a BASH script. + Hence, it is for the CI/CD configuration to determine when + the CI/CD is triggered. It might be when a developer pushes + to the conflict branch, closes the created by this script + PR, etc. So the CI/CD configuration can communicate to the + developer via the MESSAGE how to relaunch automatic rebase + after resolving the conflict. + -v, --verbose Print a lot of debug information. + Note: token value will be visible in output. + +Conflict branch naming: + -<40-char-hash-of-conflicting-commit>-conflict + +Exit codes: + 0 Rebase completed successfully + 1 Some other issue encountered + 2 Conflict encountered (conflict branch created) + 3 Script logic failure + 4 Multiple conflict branches found + 5 No rebase needed + 6 The last successful rebase has not been managed properly. + +The error code "4" means the git history of the first repository contains +several branches that match the conflict branch naming described above, but the +names differ by the commit hash. Script uses the commit hash as a base for +creating commits during rebase, and when it sees several commit hashes, it +cannot continue as it does not have any logic to decide which commit hash to +use. In such a case, the developer should either delete all the conflict +branches and start the rebase with this script over, or delete all the conflict +branches except the correct one and try to continue rebasing with this script. + +Example: + $(basename "$0") \\ + https://github.com/you/my-fork.git my-feature \\ + https://github.com/org/upstream.git main +EOF +} + +# This function pushes a branch to a remote repository. Return codes: +# 0: Success. +# 1: Some issue. +push_branch_remote() { + local token="$1" + local branch="$2" + local remote="$3" + + # The remote URL must contain the token for the ref to be modified on the + # remote via personal access token authentication: + git remote get-url "$remote" 2> "$TMP_LOG_FILE" | grep -F "$token" &> "$TMP_LOG_FILE" || return 1 + git push "$remote" "$branch" &> "$TMP_LOG_FILE" || return 1 + + return 0 +} + +# This function deletes a branch on a remote repository. Return codes: +# 0: Success. +# 1: Some issue. +# 2: The function tried to delete a branch that was not created by this script +# and probably belongs to somebody else. +delete_branch_remote() { + local token="$1" + local branch="$2" + local remote="$3" + local temp="" + local commit="" + + # Some checks to make sure that we are deleting the branch created by this + # script and not some other branch: + # 1. The branch must match the pattern for branches with conflicts that are + # created by this script: + echo "$branch" | grep -E '.*-[a-z0-9]{40}-conflict' &> "$TMP_LOG_FILE" || return 2 + # 2. The branch name must contain a hash of existing commit + temp="${branch%-conflict}" + commit="${temp##*-}" + git show "$commit" &> "$TMP_LOG_FILE" || return 2 + # The checks above are reasonable but not sufficient, as there is a + # probability that a branch that will match the pattern will be created by a + # user. At git level we do not have access to the information on who created + # the branch. But if we have access the following check could be implemented + # (the "could be" means it has not been tested yet): + # + # curl -H "Authorization: Bearer $token" \ + # "https://api.github.com/orgs//audit-log?phrase=create+branch" + # + # This check seems to require organization level access for the token. + + # The remote URL must contain the token for the ref to be modified on the + # remote via personal access token authentication: + git remote get-url "$remote" 2> "$TMP_LOG_FILE" | grep -F "$token" &> "$TMP_LOG_FILE" || return 1 + git push "$remote" --delete "$branch" &> "$TMP_LOG_FILE" || return 1 + + return 0 +} + +# This function creates a PR on the remote repository. Return codes: +# 0: Success. +# 1: Some issue. +# 2: PR was not created. +create_pr_remote() { + local token="$1" + local repo_url="$2" + local head_branch="$3" + local base_branch="$4" + local pr_title="$5" + local pr_body="$6" + local payload repo_path owner repo_name response http_code pr_url body + + + # Derive owner/repo from the URL (supports + # https://github.com/OWNER/REPO[.git]): + repo_path="$(echo "$repo_url" | sed -E 's|.*github\.com[:/]||; s|\.git$||')" + owner="$(cut -d'/' -f1 <<< "$repo_path")" + repo_name="$(cut -d'/' -f2 <<< "$repo_path")" + local api_url="https://api.github.com/repos/${owner}/${repo_name}/pulls" + + if [[ -z "$owner" || -z "$repo_name" ]]; then + echo "ERROR: Could not parse owner/repo from URL: $repo_url" >&2 + return 1 + fi + + if [[ -z "$pr_title" || -z "$pr_body" ]]; then + echo "ERROR: No PR title and/or PR body provided" >&2 + return 1 + fi + + if command -v jq &>/dev/null; then + payload="$(jq -n \ + --arg title "$pr_title" \ + --arg head "$head_branch" \ + --arg base "$base_branch" \ + --arg body "$pr_body" \ + '{title: $title, head: $head, base: $base, body: $body}')" + elif command -v python3 &>/dev/null; then + payload="$(python3 -c " +import json, sys +print(json.dumps({ + 'title': sys.argv[1], + 'head': sys.argv[2], + 'base': sys.argv[3], + 'body': sys.argv[4], +}))" "$pr_title" "$head_branch" "$base_branch" "$pr_body")" + else + echo "ERROR: jq or python3 is required to work on the JSON payload." >&2 + return 1 + fi + + response="$(curl -s -w "\n%{http_code}" \ + -X POST "$api_url" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${token}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + -H "Content-Type: application/json" \ + -d "$payload")" + + http_code="$(tail -n1 <<< "$response")" + body="$(head -n -1 <<< "$response")" + + if [[ "$http_code" != "201" ]]; then + echo "ERROR: GitHub API returned HTTP ${http_code}:" >&2 + echo "$response" >&2 + return 1 + fi + + if command -v jq &>/dev/null; then + pr_url="$(jq -r '.html_url' <<< "$body")" + elif command -v python3 &>/dev/null; then + pr_url="$(python3 -c "import json,sys; print(json.load(sys.stdin)['html_url'])" <<< "$body")" + else + echo "ERROR: jq or python3 is required to work on the JSON payload." >&2 + return 1 + fi + + echo "Pull request created: ${pr_url}" + + return 0 +} + +error_handling() { + exit_code=$? + if [ $exit_code -ne 0 ]; then + echo "ERROR: $BASH_COMMAND failed!" >&2 + echo -e "The logs from the last executed command:\n" >&2 + [ -f "${TMP_LOG_FILE:-}" ] && cat "$TMP_LOG_FILE" >&2 + fi + + rm -f "${TMP_LOG_FILE:-}" + rm -rf "${WORK_DIR:-}" +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + -v|--verbose) + set -x + shift + ;; + -h|--help) + usage + exit 0 + ;; + -l|--local) + LOCAL="true" + shift + ;; + --first-remote-token) + TOKEN="$2" + shift 2 + ;; + --commit-user-name) + COMMIT_USER_NAME="$2" + shift 2 + ;; + --commit-user-email) + COMMIT_USER_EMAIL="$2" + shift 2 + ;; + --cicd-trigger-resume) + CICD_TRIGGER_RESUME="$2" + shift 2 + ;; + -*) + echo "ERROR: Unknown option $1" >&2 + usage + exit 1 + ;; + *) + POSITIONAL_ARGS+=( "$1" ) + shift + ;; + esac + done +} + +# This function prepares remote repository URL for usage via GitHub's personal +# access token authentication. Return codes: +# 0: Success. +# 1: No HTTPS protocol prefix found in the remote repository URL. +build_url_with_token() { + local url="$1" + local token="$2" + local repo_path + + # The limit on HTTPS only is because of the personal access token that is + # used here and works only over HTTPS: + echo "$url" | grep 'https://' &> "$TMP_LOG_FILE" || return 1 + + repo_path="$(echo "$url" | sed -E 's|.*github\.com[:/]||; s|\.git$||')" + echo "https://$token@github.com/$repo_path.git" 2> "$TMP_LOG_FILE" + + return 0 +} + +# This function checks whether a rebase is needed. Return codes: +# 0: Rebase is needed. +# 1: Rebase is not needed. +check_for_rebase() { + local head_ref="$1" + local newbase="$2" + local result + + result="$(git log "$head_ref".."$newbase" --oneline 2> "$TMP_LOG_FILE")" + + if [[ -z "$result" ]]; then + return 1 + fi + + return 0 +} + +# This function checks whether a PR from branch A to branch B exists and is open +# on remote. Return codes: +# 0: Does exist. +# 1: Does not exist. +check_for_pr() { + local token="$1" + local repo_url="$2" + local branch1="$3" + local branch2="$4" + local result=1 + local response repo_path owner repo_name + + # Derive owner/repo from the URL (supports + # https://github.com/OWNER/REPO[.git]): + repo_path="$(echo "$repo_url" | sed -E 's|.*github\.com[:/]||; s|\.git$||')" + owner="$(cut -d'/' -f1 <<< "$repo_path")" + repo_name="$(cut -d'/' -f2 <<< "$repo_path")" + local api_url="https://api.github.com/repos/${owner}/${repo_name}/pulls" + + response="$(curl -s -H "Authorization: Bearer $token" \ + "$api_url?head=$owner:$branch1&base=$branch2&state=open")" + + # The conclusion is based on the response body length. If the response + # contains any objects - PR exists, if not - PR does not exist. + if command -v jq &>/dev/null; then + result="$(jq 'length' <<< "$response")" + elif command -v python3 &>/dev/null; then + result="$(python3 -c "import sys, json; print(len(json.load(sys.stdin)))" <<< "$response")" + fi + + if [ "$result" -eq 0 ]; then + return 1 + fi + + return 0 +} + +# This function checks if conflict has been resolved and the commit with the +# resolved conflict is present on the conflict branch. Return codes: +# 0: The conflict is resolved and the commit is present. +# 1: The conflict is not resolved or the commit is not present. Or in some other +# undefined state +check_if_resolved() { + local head_ref="$1" + local base="$2" + local newbase="$3" + local commits1 commits1_num commits2 commits2_num + + commits1="$(git log "$newbase".."$head_ref" --oneline)" + commits2="$(git log "$newbase".."$base" --oneline)" + + commits1_num=$(printf '%s' "$commits1" | grep -c '.' ) + commits2_num=$(printf '%s' "$commits2" | grep -c '.' ) + + if [[ $commits1_num -eq $commits2_num ]]; then + return 0 + fi + + return 1 +} + +# This function checks for empty commits between two refs. There is no return +# values. Instead it prints a list of empty commits to the STDOUT. +check_for_empty_commits() { + local base="$1" + local head_ref="$2" + local commits="" + local temp="" + + temp="$(git log "$base".."$head_ref" --format="%H %s" 2> "$TMP_LOG_FILE")" + while IFS= read -r sha msg; do + [ -z "$(git diff-tree --no-commit-id -r "$sha" 2> "$TMP_LOG_FILE")" ] \ + && commits+=" +$sha $msg" + done <<< "$temp" + + echo "$commits" +} + +# Configuration and initial values: +declare -a BRANCHES +declare BRANCH_TEMP +LOCAL="" +TOKEN="" +BRANCH="" +COMMIT="" +COMMIT_USER_NAME="github-actions[bot]" +COMMIT_USER_EMAIL="github-actions[bot]@users.noreply.github.com" +CICD_TRIGGER_RESUME="" +REBASE_HEAD_FILE=".git/REBASE_HEAD" +TMP_LOG_FILE="$(mktemp)" + + +POSITIONAL_ARGS=() +parse_args "$@" +set -- "${POSITIONAL_ARGS[@]}" + +if [[ "${#POSITIONAL_ARGS[@]}" -lt "2" ]]; then + usage + exit 1 +fi +FIRST_REPO="$1" +FIRST_REPO_BRANCH="$2" +FIRST_REPO_REMOTE_NAME="origin" +if [[ -z "$LOCAL" ]]; then + if [[ "${#POSITIONAL_ARGS[@]}" -ne "4" ]]; then + usage + exit 1 + fi + SECOND_REPO="$3" + SECOND_REPO_BRANCH="$4" + SECOND_REPO_REMOTE_NAME="second-repo" + SECOND_REPO_REF="$SECOND_REPO_REMOTE_NAME/$SECOND_REPO_BRANCH" + WORK_DIR=$(mktemp -d) + REPO_DIR="$WORK_DIR/repo" +else + if [[ "${#POSITIONAL_ARGS[@]}" -ne "3" ]]; then + usage + exit 1 + fi + SECOND_REPO_BRANCH="$3" + SECOND_REPO_REF="$SECOND_REPO_BRANCH" + REPO_DIR="$FIRST_REPO" +fi + +SUCCESSFUL_REBASE_PR_TITLE="Automatic rebase of branch $FIRST_REPO_BRANCH completed successfully" +SUCCESSFUL_REBASE_MESSAGE=" +Summary: +* Rebased branch $FIRST_REPO_BRANCH from repository $FIRST_REPO." + +if [[ -z "$LOCAL" ]]; then + SUCCESSFUL_REBASE_MESSAGE+=" +* New base: $SECOND_REPO_BRANCH from repository $SECOND_REPO." +else + SUCCESSFUL_REBASE_MESSAGE+=" +* New base: $SECOND_REPO_BRANCH from repository $FIRST_REPO." +fi + +SUCCESSFUL_REBASE_MESSAGE+=" + +Please, manage the rebased branch by either merging $FIRST_REPO_BRANCH-rebased +into $FIRST_REPO_BRANCH, force pushing branch $FIRST_REPO_BRANCH to include +commits from $FIRST_REPO_BRANCH-rebased, or any other way suitable for this +repository. + +Delete the branch $FIRST_REPO_BRANCH-rebased after you are done. +" + +echo "Working directory: $REPO_DIR" + +################################################################################ +# Repositories preparation +################################################################################ +# Clone first repo and checkout branch: +if [[ "$LOCAL" != "true" && -n "$TOKEN" ]]; then + repo_path="$(build_url_with_token "$FIRST_REPO" "$TOKEN")" + + echo "Cloning the first repository: $FIRST_REPO..." + git clone "$repo_path" "$REPO_DIR" &> "$TMP_LOG_FILE" + cd "$REPO_DIR" &> "$TMP_LOG_FILE" + git fetch --all &> "$TMP_LOG_FILE" + cd - &> "$TMP_LOG_FILE" + unset repo_path +elif [[ "$LOCAL" != "true" ]]; then + echo "Cloning the first repository: $FIRST_REPO..." + git clone "$FIRST_REPO" "$REPO_DIR" &> "$TMP_LOG_FILE" +fi + +echo "Checking out branch '$FIRST_REPO_BRANCH'..." +cd "$REPO_DIR" &> "$TMP_LOG_FILE" +git checkout "$FIRST_REPO_BRANCH" &> "$TMP_LOG_FILE" + +echo "Setting user name to $COMMIT_USER_NAME for commits..." +git config user.name "$COMMIT_USER_NAME" &> "$TMP_LOG_FILE" +echo "Setting user email to $COMMIT_USER_EMAIL for commits..." +git config user.email "$COMMIT_USER_EMAIL" &> "$TMP_LOG_FILE" + +# Add second repo as a remote: +if [[ "$LOCAL" != "true" && -n "$TOKEN" ]]; then + echo "Adding second repo as a remote $SECOND_REPO..." + git remote add "$SECOND_REPO_REMOTE_NAME" "$SECOND_REPO" &> "$TMP_LOG_FILE" + unset repo_path + + echo "Fetching from the second repo branch '$SECOND_REPO_BRANCH'..." + git fetch "$SECOND_REPO_REMOTE_NAME" "$SECOND_REPO_BRANCH" &> "$TMP_LOG_FILE" +elif [[ "$LOCAL" != "true" ]]; then + echo "Adding second repo as a remote $SECOND_REPO..." + git remote add "$SECOND_REPO_REMOTE_NAME" "$SECOND_REPO" &> "$TMP_LOG_FILE" + + echo "Fetching from the second repo '$SECOND_REPO_BRANCH'..." + git fetch "$SECOND_REPO_REMOTE_NAME" "$SECOND_REPO_BRANCH" &> "$TMP_LOG_FILE" +fi + +################################################################################ +# Rebasing decision logic +################################################################################ +# Check if there is a FIRST_REPO_BRANCH-rebased branch. If yes - do not start a +# new rebase, as the last successful rebase was not managed properly. +if [[ "$LOCAL" != "true" ]]; then + BRANCH_TEMP=$(git branch -r --no-column --list "$FIRST_REPO_REMOTE_NAME"/"$FIRST_REPO_BRANCH"-rebased 2> "$TMP_LOG_FILE") +else + BRANCH_TEMP=$(git branch --no-column --list "$FIRST_REPO_BRANCH"-rebased 2> "$TMP_LOG_FILE") +fi + +if echo "$BRANCH_TEMP" | grep rebased &> "$TMP_LOG_FILE"; then + echo "The last successful rebase of the branch $FIRST_REPO_BRANCH is still +present in the repository history. Please merge or delete it and restart the +automatic rebase." + exit 6 +fi +unset BRANCH_TEMP + +# Check state we are in. Checks, if this is the first rebase attempt or a +# consequently triggered after a manual conflict resolution rebase attempt. +# Search for a previous branch with a conflict: +if [[ "$LOCAL" != "true" ]]; then + BRANCH_TEMP=$(git branch -r --no-column --list "$FIRST_REPO_REMOTE_NAME"/"$FIRST_REPO_BRANCH"-*-conflict 2> "$TMP_LOG_FILE") +else + BRANCH_TEMP=$(git branch --no-column --list "$FIRST_REPO_BRANCH"-*-conflict 2> "$TMP_LOG_FILE") +fi + +while IFS= read -r line; do + BRANCHES+=("$line") +done <<< "$BRANCH_TEMP" +unset BRANCH_TEMP + +if [[ "${#BRANCHES[@]}" == "1" && -n "${BRANCHES[0]}" ]]; then + if [[ "$LOCAL" != "true" ]]; then + BRANCH="${BRANCHES[0]##*/}" + git checkout -b "$BRANCH" "$FIRST_REPO_REMOTE_NAME/$BRANCH" + else + BRANCH="${BRANCHES[0]##* }" + fi + echo "Continuing rebase of the branch '$FIRST_REPO_BRANCH' from the last commit in branch '$BRANCH'..." + temp="${BRANCH%-conflict}" + COMMIT="${temp##*-}" +elif [[ "${#BRANCHES[@]}" == "1" && -z "${BRANCHES[0]}" ]]; then + echo "Starting a new rebase..." +else + echo "ERROR: Repository has several conflict branches for the '$FIRST_REPO_BRANCH' and needs cleanup, exiting..." >&2 + exit 4 +fi +unset BRANCHES + +################################################################################ +# Attempt/continue rebase +################################################################################ +if [[ -z "$COMMIT" && -z "$BRANCH" ]]; then + if ! check_for_rebase "$FIRST_REPO_BRANCH" "$SECOND_REPO_REF"; then + echo "Current branch $FIRST_REPO_BRANCH is up to date with $SECOND_REPO_REF." + exit 5 + fi + + echo "Rebasing '$FIRST_REPO_BRANCH' onto '$SECOND_REPO_REF'..." + + # The check_if_resolved() function checks whether the conflict has been + # resolved by comparing the number of commits on the conflict branch, the + # default behavior of the git rebase is to drop empty commits that are there + # result of rebase operation. This could mess up the check_if_resolved(). + # Hence, we better keep the empty commits on the branch but inform the + # developer about them. + if git rebase --empty=keep "$SECOND_REPO_REF" &> "$TMP_LOG_FILE"; then + echo "Rebase completed successfully. No conflicts." + + # Do not push to the same branch on the remote repository to avoid + # force pushes: + git checkout -b "$FIRST_REPO_BRANCH-rebased" "$FIRST_REPO_BRANCH" &> "$TMP_LOG_FILE" + empty_commits="$(check_for_empty_commits "$SECOND_REPO_REF" "$FIRST_REPO_BRANCH-rebased")" + + if [ -n "$empty_commits" ]; then + SUCCESSFUL_REBASE_MESSAGE+=" +$empty_commits + +You might want to drop them." + fi + + if [[ "$LOCAL" != "true" && -n "$TOKEN" ]]; then + push_branch_remote "$TOKEN" "$FIRST_REPO_BRANCH-rebased" "$FIRST_REPO_REMOTE_NAME" + if ! check_for_pr "$TOKEN" "$FIRST_REPO" "$FIRST_REPO_BRANCH-rebased" "$FIRST_REPO_BRANCH" ; then + create_pr_remote "$TOKEN" "$FIRST_REPO" "$FIRST_REPO_BRANCH-rebased" "$FIRST_REPO_BRANCH" "$SUCCESSFUL_REBASE_PR_TITLE" "$SUCCESSFUL_REBASE_MESSAGE" + fi + else + echo "$SUCCESSFUL_REBASE_MESSAGE" + fi + exit 0 + fi +elif [[ -n "$COMMIT" && -n "$BRANCH" ]]; then + if ! check_if_resolved "$BRANCH" "$COMMIT" "$SECOND_REPO_REF"; then + echo "ERROR: still a conflict." >&2 + exit 2 + fi + + echo "Continuing rebase '$FIRST_REPO_BRANCH' onto '$BRANCH' using commit $COMMIT as a base..." + + # The check_if_resolved() function checks whether the conflict has been + # resolved by comparing the number of commits on the conflict branch, the + # default behavior of the git rebase is to drop empty commits that are there + # result of rebase operation. This could mess up the check_if_resolved(). + # Hence, we better keep the empty commits on the branch but inform the + # developer about them. + if git rebase --empty=keep --onto "$BRANCH" "$COMMIT" "$FIRST_REPO_BRANCH" &> "$TMP_LOG_FILE"; then + # Delete the temporary conflict branch so there is no leftovers after a + # success: + git branch --delete "$BRANCH" &> "$TMP_LOG_FILE" + git checkout -b "$FIRST_REPO_BRANCH-rebased" "$FIRST_REPO_BRANCH" &> "$TMP_LOG_FILE" + empty_commits="$(check_for_empty_commits "$SECOND_REPO_REF" "$FIRST_REPO_BRANCH-rebased")" + + if [ -n "$empty_commits" ]; then + SUCCESSFUL_REBASE_MESSAGE+=" +$empty_commits + +You might want to drop them." + fi + + if [[ "$LOCAL" != "true" && -n "$TOKEN" ]]; then + # Delete the temporary conflict branch on remote so there is no + # leftovers after a success: + delete_branch_remote "$TOKEN" "$BRANCH" "$FIRST_REPO_REMOTE_NAME" + + # Do not push to the same branch on the remote repository to avoid + # force pushes: + push_branch_remote "$TOKEN" "$FIRST_REPO_BRANCH-rebased" "$FIRST_REPO_REMOTE_NAME" + if ! check_for_pr "$TOKEN" "$FIRST_REPO" "$FIRST_REPO_BRANCH-rebased" "$FIRST_REPO_BRANCH" ; then + create_pr_remote "$TOKEN" "$FIRST_REPO" "$FIRST_REPO_BRANCH-rebased" "$FIRST_REPO_BRANCH" "$SUCCESSFUL_REBASE_PR_TITLE" "$SUCCESSFUL_REBASE_MESSAGE" + fi + else + echo "$SUCCESSFUL_REBASE_MESSAGE" + fi + + echo "Rebase completed successfully. No new conflicts." + exit 0 + fi +else + echo "ERROR: Oh no, something went wrong! The script cannot continue the rebase." >&2 + exit 3 +fi + +################################################################################ +# Conflict handling: +################################################################################ +# The strategy: never try to resolve conflicts on your own, ask a developer +# instead. +echo "Conflict detected. Inspecting rebase state..." + +if [[ ! -f "$REBASE_HEAD_FILE" ]]; then + echo "ERROR: Expected .git/REBASE_HEAD not found. Cannot determine conflicting commit." >&2 + git rebase --abort &> "$TMP_LOG_FILE" + exit 3 +fi + +CONFLICT_COMMIT=$(cat "$REBASE_HEAD_FILE" 2> "$TMP_LOG_FILE") + +# Build the conflict branch name: --conflict +CONFLICT_BRANCH="${FIRST_REPO_BRANCH}-${CONFLICT_COMMIT}-conflict" + + +# Create a branch at HEAD (last successfully rebased commit) before aborting to +# save the current state of the rebase. Delete the previous temporary conflict +# branch to prevent the situation that cause this script to return code 4 (see +# the usage). If the branch that was used during the rebase has the same name as +# the CONFLICT_BRANCH - it means that the conflict was either not resolved nor +# pushed to the branch after resolution by the developer. Hence, no need to +# create a branch. +if [[ "$BRANCH" != "$CONFLICT_BRANCH" ]]; then + git branch "$CONFLICT_BRANCH" &> "$TMP_LOG_FILE" + if [[ "$LOCAL" != "true" && -n "$TOKEN" ]]; then + push_branch_remote "$TOKEN" "$CONFLICT_BRANCH" "$FIRST_REPO_REMOTE_NAME" + fi + + if [[ -n "$BRANCH" ]]; then + git branch --delete "$BRANCH" &> "$TMP_LOG_FILE" + if [[ "$LOCAL" != "true" && -n "$TOKEN" ]]; then + delete_branch_remote "$TOKEN" "$BRANCH" "$FIRST_REPO_REMOTE_NAME" + fi + fi +fi + +git rebase --abort &> "$TMP_LOG_FILE" + +################################################################################ +# Opening a PR/communicating via CLI with instructions on how to proceed: +################################################################################ +message="Automatic rebase of branch '$FIRST_REPO_BRANCH' met a conflict. + +Summary: +* First repo : $FIRST_REPO +* First repo branch : $FIRST_REPO_BRANCH +" +if [[ "$LOCAL" != "true" ]]; then + message+=" +* Second repo : $SECOND_REPO +* Second repo branch : $SECOND_REPO_BRANCH" +fi +message+=" +* Branch with the successfully rebased commits : $CONFLICT_BRANCH +* The commit that introduced the conflict : $CONFLICT_COMMIT + +Before relaunching the automatic rebase, please do the following to solve the +conflict:" + +if [[ "$LOCAL" != "true" && -n "$TOKEN" ]]; then +message+=" +1. Fetch the remote repository: + + \`\`\` + git clone $FIRST_REPO + \`\`\` + +2. Enter the repository. +3. Checkout the conflict branch created by the script: + + \`\`\` + git checkout $CONFLICT_BRANCH + \`\`\` + +4. Cherry-pick the commit that introduced the conflict + + \`\`\` + git cherry-pick $CONFLICT_COMMIT + \`\`\` + +5. Solve the conflict and apply the commit after solving the conflict on top of + the conflict branch. Important: if the conflict resolution resulted in an + empty commit or you have decided not to resolve the conflict but to drop the + commit - you must still add one commit to the $CONFLICT_BRANCH, even if it + is an empty commit. Otherwise the automated rebase will not continue. + + \`\`\` + git add . + git cherry-pick --continue + \`\`\` + +6. Push the remote repository. + + \`\`\` + git push origin $CONFLICT_BRANCH + \`\`\` + +" + + if [[ -n "$CICD_TRIGGER_RESUME" ]]; then + message+="$CICD_TRIGGER_RESUME" + fi + + message+=" + +If you want to start the automatic rebase from the beginning, then make sure to: + +* Remove the $CONFLICT_BRANCH from the remote repository. +* Close this PR. +" +else +message+=" +1. Enter the repository. +2. Checkout the conflict branch created by the script: + + git checkout $CONFLICT_BRANCH + +3. Cherry-pick the commit that introduced the conflict + + git cherry-pick $CONFLICT_COMMIT + +4. Solve the conflict and apply the commit after solving the conflict on top of + the conflict branch: + + git add . + git cherry-pick --continue + +5. Try to do the rebase with this script again and wait either for a new + conflict or for the rebase to be finished, e.g.: + + ./rebase.sh --local $REPO_DIR $FIRST_REPO_BRANCH $SECOND_REPO_BRANCH +" +fi + + +if [[ "$LOCAL" != "true" && -n "$TOKEN" ]]; then + pr_body="$message" + pr_title="Automatic rebase of branch '$FIRST_REPO_BRANCH' met a conflict." + + if ! check_for_pr "$TOKEN" "$FIRST_REPO" "$CONFLICT_BRANCH" "$FIRST_REPO_BRANCH" ; then + create_pr_remote "$TOKEN" "$FIRST_REPO" "$CONFLICT_BRANCH" "$FIRST_REPO_BRANCH" "$pr_title" "$pr_body" + fi +else + echo "$message" +fi + +exit 2 diff --git a/scripts/woodpecker-trigger.sh b/scripts/woodpecker-trigger.sh new file mode 100755 index 0000000..ff84315 --- /dev/null +++ b/scripts/woodpecker-trigger.sh @@ -0,0 +1,214 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: 2026 3mdeb +# +# SPDX-License-Identifier: Apache-2.0 + +trap cleanup EXIT +set -euo pipefail + +usage() { + cat <&2 + return 1 + ;; + esac +} + +# Prints the architecture component of the woodpecker-cli binary name. +# Return codes: +# 0: Success. +# 1: Unsupported architecture. +detect_arch() { + case "$(uname -m)" in + x86_64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + armv7l) echo "arm-7" ;; + i386|i686) echo "386" ;; + *) + echo "ERROR: Unsupported architecture: $(uname -m)" >&2 + return 1 + ;; + esac +} + +# Downloads the woodpecker-cli binary for the current platform to a temporary +# directory. Sets the global WP_BIN and WP_TMPDIR variables. +# Return codes: +# 0: Success. +# 1: Download or platform detection failed. +download_woodpecker_cli() { + local version="$1" + local os arch filename url + + os="$(detect_os)" + arch="$(detect_arch)" + WP_TMPDIR="$(mktemp -d)" + WP_BIN="${WP_TMPDIR}/woodpecker-cli" + + filename="woodpecker-cli_${os}_${arch}.tar.gz" + url="https://github.com/woodpecker-ci/woodpecker/releases/download/v${version}/${filename}" + + echo "Downloading woodpecker-cli v${version} (${os}/${arch})..." + curl -sL --fail -o "${WP_TMPDIR}/${filename}" "$url" || { + echo "ERROR: Failed to download woodpecker-cli from: ${url}" >&2 + return 1 + } + tar -xzf "${WP_TMPDIR}/${filename}" -C "$WP_TMPDIR" woodpecker-cli + chmod +x "$WP_BIN" +} + + +# Pinned woodpecker-cli version. Override with the WOODPECKER_CLI_VERSION +# environment variable. +# Check https://github.com/woodpecker-ci/woodpecker/releases for available +# versions. +WOODPECKER_CLI_VERSION="${WOODPECKER_CLI_VERSION:-3.13.0}" +WP_BIN="" +WP_TMPDIR="" +TOKEN="" +API_URL="" +OWNER="" +REPO="" +REF="main" +INPUTS=() + +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + -v|--verbose) + set -x + shift + ;; + -h|--help) + usage + exit 0 + ;; + -t|--token) + TOKEN="$2" + shift 2 + ;; + -u|--api-url) + API_URL="$2" + shift 2 + ;; + -o|--owner) + OWNER="$2" + shift 2 + ;; + -r|--repo) + REPO="$2" + shift 2 + ;; + --ref) + REF="$2" + shift 2 + ;; + --input) + INPUTS+=("$2") + shift 2 + ;; + -*) + echo "ERROR: Unknown option $1" >&2 + usage + exit 1 + ;; + *) + echo "ERROR: Unexpected argument '$1'" >&2 + usage + exit 1 + ;; + esac + done +} + +parse_args "$@" + +# Validate required parameters: +errors=0 +[[ -z "$TOKEN" ]] && { echo "ERROR: --token is required." >&2; errors=$((errors+1)); } +[[ -z "$API_URL" ]] && { echo "ERROR: --api-url is required." >&2; errors=$((errors+1)); } +[[ -z "$OWNER" ]] && { echo "ERROR: --owner is required." >&2; errors=$((errors+1)); } +[[ -z "$REPO" ]] && { echo "ERROR: --repo is required." >&2; errors=$((errors+1)); } +if [[ $errors -gt 0 ]]; then + usage + exit 1 +fi + +# Strip trailing slash from API_URL: +API_URL="${API_URL%/}" + +# Download woodpecker-cli and register cleanup on exit: +download_woodpecker_cli "$WOODPECKER_CLI_VERSION" + +# Build the pipeline create command. +cmd=("$WP_BIN" pipeline create "${OWNER}/${REPO}" --branch "$REF") +for var in "${INPUTS[@]}"; do + cmd+=(--var "$var") +done + +echo "Triggering pipeline for ${OWNER}/${REPO} on branch '${REF}'..." +WOODPECKER_SERVER="$API_URL" WOODPECKER_TOKEN="$TOKEN" "${cmd[@]}" + +echo "Pipeline triggered successfully."